@agentpulselabs/cli-plugin-pulse 0.2.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.
- package/README.md +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
package/README.md
CHANGED
|
@@ -1,48 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pulse connector plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
discipline — to the `agentpulse` CLI. It is a thin `argv → core → stdout` seam over
|
|
5
|
-
[`@agentpulselabs/pulse`](https://github.com/ashishcloud/agentpulse-pulse); all discipline lives in the core.
|
|
3
|
+
`@agentpulselabs/cli-plugin-pulse` contributes the `agentpulse pulse` command group.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
> (`@agentpulselabs/cli`) supports plugins via `@oclif/plugin-plugins`; this plugin installs through it.
|
|
5
|
+
Commands:
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
```text
|
|
8
|
+
agentpulse pulse install
|
|
9
|
+
agentpulse pulse pair [--org <slug>] [--platform <url>] --root <rootId>=<path>
|
|
10
|
+
agentpulse pulse login [--platform <url>]
|
|
11
|
+
agentpulse pulse start
|
|
12
|
+
agentpulse pulse status
|
|
13
|
+
agentpulse pulse doctor
|
|
14
|
+
agentpulse pulse stop
|
|
15
|
+
agentpulse pulse restart
|
|
16
|
+
agentpulse pulse mirror on|off|status
|
|
17
|
+
agentpulse pulse hub-write on|off|status
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Replaces the deprecated build-discipline Pulse plugin
|
|
21
|
+
|
|
22
|
+
Version 1.0.0 takes over the package name and the `pulse` topic from the deprecated build-discipline plugin (`@agentpulselabs/cli-plugin-pulse` 0.2.0, with `pulse gate|next|start|status|submit|verify`). Those commands are gone. The connector was previously developed as `@agentpulselabs/cli-plugin-hub`.
|
|
23
|
+
|
|
24
|
+
Remove the old plugin first for a clean replace, then install or link the connector. Always use the full scoped name; a bare `pulse` resolves to an unrelated npm package.
|
|
11
25
|
|
|
12
26
|
```bash
|
|
13
|
-
|
|
14
|
-
agentpulse plugins install @agentpulselabs/cli-plugin-pulse
|
|
15
|
-
agentpulse
|
|
27
|
+
agentpulse plugins uninstall @agentpulselabs/cli-plugin-pulse
|
|
28
|
+
agentpulse plugins install @agentpulselabs/cli-plugin-pulse # once published
|
|
29
|
+
agentpulse plugins link <path-to-this-repo> # local development instead of install
|
|
16
30
|
```
|
|
17
31
|
|
|
18
|
-
|
|
19
|
-
same mechanism any third party uses to add `agentpulse` commands.
|
|
32
|
+
`agentpulse plugins update` does not move you to 1.0.0, because the old install is pinned to `0.2.0`; run `plugins install` explicitly. After `plugins unlink`, no `pulse` plugin is left, so install it again.
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
A machine paired before the rename keeps its pairing, sign-in and state: the Keychain items, the `~/.config/agentpulse/hub` directory, the service label and the wire identifiers did not change. The installed launchd or systemd service still holds the absolute paths of the old install, though. If the daemon path changed, or the node binary it names was removed (for example by a node upgrade), `agentpulse pulse status`, `agentpulse pulse doctor` and `agentpulse pulse start` report `HUB_SERVICE_PATH_STALE`. Run `agentpulse pulse install` once, then `agentpulse pulse start`.
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|---|---|
|
|
25
|
-
| `agentpulse pulse start --requirements "…" --name <name>` | Begin an engagement in the current repo (`.pulse/`). |
|
|
26
|
-
| `agentpulse pulse next` | Emit the current phase work-list (prompts + JSON schema; contract injected). |
|
|
27
|
-
| `agentpulse pulse submit --results-file r.json` | Validate your model's structured results vs schema + contract. |
|
|
28
|
-
| `agentpulse pulse gate --approved [--decisions-file d.json]` | The human gate — advance the phase; lock product decisions. |
|
|
29
|
-
| `agentpulse pulse verify` | Run the contract's gate command (`npm run check`); exits non-zero if red. |
|
|
30
|
-
| `agentpulse pulse status` | Phase, contract, board progress. |
|
|
36
|
+
## How it works
|
|
31
37
|
|
|
32
|
-
The
|
|
33
|
-
(Cursor/Codex/Claude). This CLI plugin is the universal shell fallback: same core, same `.pulse/` state.
|
|
38
|
+
The plugin manages a separate launchd (macOS) or systemd-user (Linux) companion. The oclif command process is never the daemon. Local configuration and state are owner-only. The OAuth tokens and the local Ed25519 key live only in Keychain or libsecret; if neither is available the connector fails closed.
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
Authentication is the platform's OAuth (Phase 1). `agentpulse pulse pair` and `agentpulse pulse login` run the same browser PKCE flow as `agentpulse login` (public client `agentpulse-cli`, loopback `127.0.0.1`), but keep their own token store and never write the CLI's `config.json`. The CLI config is only read for the default platform URL and dev-org slug.
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
Pairing resolves the dev-org through `/api/team/dev-orgs`, mints a dev-org token through `/api/team/dev-orgs/:id/cli-token`, and registers the machine with `POST /api/agent-hub/user/machines` and a frozen least-authority grant. Every connector request carries `Authorization: Bearer`, `X-Serverless-Authorization` on Cloud Run dev-orgs, and the machine, session and policy-version headers.
|
|
43
|
+
|
|
44
|
+
If the platform can't be reached to refresh the sign-in, the daemon reports `reconnecting`, claims nothing and retries with backoff. When a refresh is refused (`invalid_grant`), it stops claiming work and `agentpulse pulse status` prints the commands to run. Today that refusal is expected about every 12 hours, until the platform fixes refresh for the `agentpulse-cli` client. A revoked machine stops for good; signing in again never revives it. The Ed25519 identity is kept locally for Phase 2 (DPoP) and is not used for authentication.
|
|
45
|
+
|
|
46
|
+
The daemon restarts itself by exiting with code 75; launchd (`KeepAlive.SuccessfulExit=false`) and systemd (`Restart=on-failure`) start it again. It does so when any file under its own `src/` (data files such as `mirror-contract.json` included, dotfiles not) or its `package.json` changes (2 s after the last write, and only if the contents differ), when `restart.request` appears in its config directory, or when the cycle or a mirror pass makes no progress for 3 minutes (`cycle_stalled` in `daemon-error.log`). A code or request restart drains first: it claims no new job, lets running jobs finish and settle (health shows `restarting` with the job ids), and only aborts them, as `HUB_DAEMON_RESTARTING`, after 15 minutes. A stall aborts at once. A request older than the running process is dropped, since the restart it asked for already happened. `agentpulse pulse restart` writes that file and waits up to 60 s for a new process, so it needs no `launchctl` or `systemctl`; it refuses when the daemon has no fresh heartbeat. After more than 5 restarts in 5 minutes the daemon stays up instead, `agentpulse pulse status` reports `restart_loop`, and a pending restart is deferred until the window clears. `pulse stop` and SIGTERM always win and exit 0.
|
|
47
|
+
|
|
48
|
+
The connector accepts only registered intents:
|
|
49
|
+
|
|
50
|
+
- `workspace.read` and `workspace.write`
|
|
51
|
+
- `git.read` and `git.write`
|
|
52
|
+
- `worktree.create`
|
|
53
|
+
- `agent.cursor.invoke` and `agent.claude.invoke`
|
|
54
|
+
- `hub.write`, only after `agentpulse pulse hub-write on`
|
|
55
|
+
|
|
56
|
+
It does not accept shell commands, absolute cloud paths, package installation, secret reads, permission changes, push, release, production actions, or remote grant expansion.
|
|
57
|
+
|
|
58
|
+
## Acting on the local Agent Hub from Pulse
|
|
59
|
+
|
|
60
|
+
`agentpulse pulse hub-write on` lets Pulse post to the local Agent Hub as you: a chat message, a NUDGE or STATUS? to agents, a decision or change request on a stream, accepting a delivery, or a reply to a bus ask. It is off on a new pairing. `on` and `off` replace this machine's cloud grant with `hub.write` added or removed (nothing else changes) and store the new policy version in the daemon config; the daemon adopts it on its next heartbeat. `status` reads the grant from Pulse.
|
|
61
|
+
|
|
62
|
+
Each action arrives as a typed `hub.*` job and becomes exactly one POST to the hub on `127.0.0.1` with the hub token, as the principal `user`, to `/api/chat/post`, `/api/message`, `/api/decision`, `/api/accept` or `/bus/ack`, and nothing else. If the hub may have taken a write whose result was never recorded (the daemon stopped mid-request, or the hub did not answer), the action ends as `hub_write_unconfirmed` instead of being sent twice; check the hub before sending it again. A hub that is not running gives `hub_unreachable`; a write the hub rejects gives `hub_refused` with its reason.
|
|
63
|
+
|
|
64
|
+
## Mirroring the local Agent Hub
|
|
65
|
+
|
|
66
|
+
`agentpulse pulse mirror on` lets the paired daemon mirror your local Agent Hub into Pulse, so its deliveries, streams, agents, graph, chat (task, project, one-to-one and bus threads), inbox and Cursor rule packs show there read-only. It is off on a new install. `on` and `off` change only `mirror.localHub` in the daemon config, and the daemon picks the change up within a heartbeat. `off` stops pushing; items already mirrored stay in Pulse. `status` shows the last sync, how many items are mirrored, and how many were not mirrored, by kind and reason.
|
|
67
|
+
|
|
68
|
+
The mirror needs the `agent-hub:connector:mirror` scope. A sign-in from before this version lacks it; run `agentpulse pulse login` once.
|
|
69
|
+
|
|
70
|
+
How it reads and what it sends:
|
|
71
|
+
|
|
72
|
+
- It finds the hub from the `com.agentpulse.agents-hub` launch agent (or `mirror.hub.dir` and `mirror.hub.port` in the daemon config) and only sends GETs to an allow-list of endpoints on `127.0.0.1`, with the hub's `state/hub.token`. It never writes to the hub, never runs `hub.mjs`, and never opens `state/` files other than the token.
|
|
73
|
+
- It follows the hub's `/api/stream` feed, re-reads only the affected endpoints about 300 ms after a change, runs a full reconcile 60 seconds after the last one ended, and reconnects with backoff.
|
|
74
|
+
- The four graph windows (which also carry edges and bus messages) are the hub's slowest read. After a change they are re-read in a pass of their own, at most once every 3 seconds counted from the end of the last one. The hub announces each of the connector's own graph reads as a change, so a hub change that arrives during one of those reads or within 1.5 seconds after it is most likely an echo, so it re-reads the windows only after 10 seconds; any other change brings the 3 second gap back. Task briefs follow in the next pass, four at a time and for at most 3 seconds. `agentpulse pulse mirror status` shows how long the last pass took.
|
|
75
|
+
- Only new or changed items are sent, in batches of at most 500 items and 2 MiB. Items that disappear are deleted in Pulse on the full reconcile, and only for sources that were read completely.
|
|
76
|
+
- Absolute paths are rewritten before upload: paths in a known repo become repo-relative, workspace and home paths lose their prefix, and any other path keeps only its last segment. A home folder never shows its user name. An item that still carries a path, a token-looking string or the hub token is withheld and counted, never sent.
|
|
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
|
+
- Transcripts, diffs, raw provider output, the hub token, Keychain and OAuth material are never read for the mirror or sent.
|
|
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`.
|
|
43
125
|
|
|
44
|
-
##
|
|
126
|
+
## Local checks
|
|
45
127
|
|
|
46
128
|
```bash
|
|
47
|
-
|
|
129
|
+
npm test
|
|
130
|
+
npm run check
|
|
48
131
|
```
|
package/package.json
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentpulselabs/cli-plugin-pulse",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|
|
7
|
-
"
|
|
8
|
-
"access": "public"
|
|
9
|
-
},
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/ashishcloud/agentpulse-cli-plugin-pulse.git"
|
|
13
|
-
},
|
|
14
|
-
"homepage": "https://github.com/ashishcloud/agentpulse-cli-plugin-pulse#readme",
|
|
15
|
-
"bugs": {
|
|
16
|
-
"url": "https://github.com/ashishcloud/agentpulse-cli-plugin-pulse/issues"
|
|
17
|
-
},
|
|
7
|
+
"private": false,
|
|
18
8
|
"files": [
|
|
19
9
|
"src",
|
|
20
10
|
"README.md"
|
|
@@ -24,26 +14,31 @@
|
|
|
24
14
|
"topicSeparator": " ",
|
|
25
15
|
"topics": {
|
|
26
16
|
"pulse": {
|
|
27
|
-
"description": "
|
|
17
|
+
"description": "Install, pair, and operate the Pulse local connector."
|
|
28
18
|
}
|
|
29
19
|
}
|
|
30
20
|
},
|
|
31
21
|
"scripts": {
|
|
32
|
-
"test": "node --test \"test
|
|
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\""
|
|
33
27
|
},
|
|
34
28
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
29
|
+
"node": ">=24"
|
|
36
30
|
},
|
|
37
31
|
"keywords": [
|
|
38
32
|
"oclif-plugin",
|
|
39
33
|
"agentpulse",
|
|
40
34
|
"pulse",
|
|
35
|
+
"agent-hub",
|
|
41
36
|
"cli"
|
|
42
37
|
],
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@agentpulselabs/pulse": "^0.1.0"
|
|
45
|
-
},
|
|
46
38
|
"peerDependencies": {
|
|
47
39
|
"@oclif/core": ">=3"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
48
43
|
}
|
|
49
44
|
}
|
|
@@ -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();
|