@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
@@ -0,0 +1,145 @@
1
+ // agents-hub workers/runner.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Hub-owned agents. The hub starts them, owns their sessions, and delivers every message by running one turn of that
3
+ // session with the message as the prompt; the turn's final answer is the reply. No parent chat has to stay alive, and
4
+ // any provider with a driver can run them.
5
+ import { execFile as execFileCb } from 'node:child_process';
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { promisify } from 'node:util';
10
+ import { isFyi } from '../core/ids.mjs';
11
+ import { withTx } from '../core/db.mjs';
12
+ import { RETRY_MS, guardTick, retryBusy } from '../core/ticks.mjs';
13
+ import { CHANNEL_POST_RULE, DECISION_RULE, WRITING_GUIDE } from '../lib/writing.mjs';
14
+ import { hubHome } from '../home.mjs';
15
+
16
+ const execFile = promisify(execFileCb);
17
+ const TICK_MS = 1500;
18
+ const LEASE_MS = 60e3;
19
+ const MAX_PARALLEL = 2;
20
+ const MAX_ATTEMPTS = 3;
21
+ const ROLE = /^[A-Z][A-Z -]{1,30}$/;
22
+ const HUB_CLI = path.join(hubHome(), 'hub.mjs');
23
+
24
+ /** What every hub-owned agent is told on its first turn, whatever its provider. */
25
+ export function briefPrompt(run, brief) {
26
+ return [
27
+ `You are the ${run.role} on a delivery run by the Agent Hub. Your hub address is ${run.id}.`,
28
+ `Work only inside ${run.workspace}. Never push, release or touch production; never request extra permissions.`,
29
+ run.stream ? `Your task channel is ${run.workspace}/.agents/${run.stream.split('/').pop()}/channel.md: append posts to it (never rewrite), each starting with a header line \`## [${run.role}] HH:MM:SS TYPE — summary\`.` : null,
30
+ `To message another agent: node "${HUB_CLI}" bus publish --as ${run.id} --topic agent/<id> --type MESSAGE --body "<text>".`,
31
+ 'Each message to you arrives as a new turn; your final answer in that turn is sent back to the sender as your reply.',
32
+ `${WRITING_GUIDE} ${CHANNEL_POST_RULE} ${DECISION_RULE}`,
33
+ '',
34
+ '## Your brief',
35
+ brief,
36
+ ].filter((x) => x !== null).join('\n');
37
+ }
38
+
39
+ export function messagePrompt(m) {
40
+ return [`Message from ${m.fromName || m.from} (${m.type}, id ${m.id}):`, '', m.text,
41
+ '', 'Answer it now; your final answer is sent back as your reply. Then carry on with your task if work remains.'].join('\n');
42
+ }
43
+
44
+ export function createRunner({ hub, drivers, run = execFile, workspaceRoot, onChange = () => {}, now = Date.now, log = console.error, retryMs = RETRY_MS }) {
45
+ const { registry, messaging } = hub;
46
+ const busy = new Map();
47
+ const aborts = new Map();
48
+ let timer = null;
49
+ const save = (r) => { registry.setFact('run', r.id, { ...r, updatedAt: Date.now() }); onChange(); return r; };
50
+ const runs = () => registry.facts('run').filter((r) => r.state !== 'stopped');
51
+
52
+ function inWorkspace(dir) {
53
+ try {
54
+ const real = fs.realpathSync(dir);
55
+ const root = fs.realpathSync(workspaceRoot);
56
+ return (real === root || real.startsWith(root + path.sep)) && fs.statSync(real).isDirectory() ? real : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ /** Start a hub-owned agent: create its session, register it, and queue its brief as its first message. */
61
+ async function start({ driver, role, workspace, brief, stream = null, parent = 'manager', name = null, mode = 'write' }) {
62
+ const d = drivers.get(driver);
63
+ if (!d) return { error: `unknown driver ${driver}` };
64
+ if (!ROLE.test(String(role || ''))) return { error: 'bad role' };
65
+ const ws = inWorkspace(workspace);
66
+ if (!ws) return { error: 'workspace must be a folder inside the hub workspace' };
67
+ if (typeof brief !== 'string' || !brief.trim()) return { error: 'empty brief' };
68
+ if (mode === 'write' && !d.can.edit) return { error: `${d.label} cannot edit; give it a read-only role` };
69
+ const id = (await d.newSession({ run, workspace: ws })) || crypto.randomUUID();
70
+ const rec = { id, driver, role, workspace: ws, stream, parent, name, mode, state: 'queued', sessionId: d.id === 'cursor' ? id : null, createdAt: Date.now(), turns: 0, costUsd: 0, lastError: null };
71
+ registry.upsertInferred({ id, node_type: 'agent', role, parent_id: parent, delivery_id: parent, stream_key: stream, persona_name: name, source: 'registered', repo: path.basename(ws), channel: stream ? stream.split('/').pop() : null, started_at: rec.createdAt, last_at: rec.createdAt });
72
+ save(rec);
73
+ messaging.publish({ from: 'user', fromName: 'You', topic: `agent/${id}`, type: 'BRIEF', body: brief.slice(0, 4000), needsAck: true, meta: { briefFull: brief.length > 4000 } }, registry.directory());
74
+ rec.brief = brief;
75
+ save(rec);
76
+ return { id, driver, state: rec.state };
77
+ }
78
+
79
+ async function turn(r, m) {
80
+ const d = drivers.get(r.driver);
81
+ const prompt = m.type === 'BRIEF' ? briefPrompt(r, r.brief || m.text) : messagePrompt(m);
82
+ save({ ...r, state: 'working', current: m.id, lastError: null });
83
+ registry.heartbeat(r.id, { event: 'runner', tool: d.id });
84
+ const keep = setInterval(() => {
85
+ try {
86
+ messaging.renewClaim(r.id, m.id, m.leaseToken, { leaseMs: LEASE_MS });
87
+ registry.heartbeat(r.id, { event: 'runner', tool: d.id });
88
+ } catch (e) { log(`runner keepalive for ${r.id} failed: ${e.message}`); }
89
+ }, 20e3);
90
+ const abort = new AbortController();
91
+ aborts.set(r.id, abort);
92
+ try {
93
+ const out = await d.turn({ run, sessionId: r.sessionId, workspace: r.workspace, prompt, mode: r.mode, signal: abort.signal });
94
+ if (abort.signal.aborted) return;
95
+ // The answer is delivered, acked and recorded together; a busy database retries that, never the turn.
96
+ await retryBusy(() => withTx(hub.db, () => {
97
+ const fresh = registry.fact('run', r.id) || r;
98
+ messaging.markDelivered(r.id, [m.id], { via: 'runner' });
99
+ messaging.ack(r.id, m.id, { reply: out.text || '(no answer)', fromName: fresh.name || fresh.role }, registry.directory());
100
+ save({ ...fresh, sessionId: out.sessionId || fresh.sessionId, state: 'idle', current: null, turns: (fresh.turns || 0) + 1, lastAt: Date.now(), costUsd: (fresh.costUsd || 0) + ((out.usage && out.usage.costUsd) || 0) });
101
+ registry.heartbeat(r.id, { event: 'stop', tool: d.id });
102
+ }, 'runner answer'), { delaysMs: retryMs, label: `runner answer for ${r.id}`, log });
103
+ } catch (e) {
104
+ if (abort.signal.aborted) return;
105
+ const failed = String(e && e.message ? e.message : e).slice(0, 500);
106
+ try {
107
+ const fresh = registry.fact('run', r.id) || r;
108
+ save({ ...fresh, state: 'error', current: null, lastError: failed, lastAt: Date.now() });
109
+ } catch (e2) { log(`runner ${r.id}: turn failed (${failed}) and recording it failed too: ${e2.message}`); }
110
+ } finally { clearInterval(keep); aborts.delete(r.id); }
111
+ }
112
+
113
+ const tick = guardTick('runner', () => {
114
+ for (const r of runs()) {
115
+ if (busy.has(r.id) || busy.size >= MAX_PARALLEL) continue;
116
+ const [m] = messaging.claim(r.id, { via: 'runner', limit: 1, leaseMs: LEASE_MS });
117
+ if (!m) continue;
118
+ if (isFyi(m) && m.type !== 'BRIEF') { messaging.ack(r.id, m.id, {}, registry.directory()); continue; }
119
+ if (m.attempts > MAX_ATTEMPTS) {
120
+ messaging.ack(r.id, m.id, { reply: `I could not run this after ${MAX_ATTEMPTS} tries. Last error: ${r.lastError || 'unknown'}` }, registry.directory());
121
+ continue;
122
+ }
123
+ busy.set(r.id, turn(r, m).catch((e) => log(`runner turn for ${r.id} failed: ${e.message}`)).finally(() => busy.delete(r.id)));
124
+ }
125
+ }, { now, log });
126
+
127
+ return {
128
+ start,
129
+ /** Stop a run: kill a turn in progress (its process) and take no more messages. */
130
+ stop(id) {
131
+ const r = registry.fact('run', id);
132
+ if (!r) return { error: 'no such run' };
133
+ const a = aborts.get(id);
134
+ if (a) a.abort();
135
+ save({ ...r, state: 'stopped', current: null });
136
+ return { ok: true, killed: !!a };
137
+ },
138
+ list() { return registry.facts('run').map(({ brief, ...r }) => ({ ...r, briefChars: brief ? brief.length : 0 })); },
139
+ drivers() { return [...drivers.values()].map((d) => ({ id: d.id, label: d.label, can: d.can })); },
140
+ tick,
141
+ begin() { if (!timer) { timer = setInterval(tick, TICK_MS); timer.unref(); } },
142
+ end() { if (timer) clearInterval(timer); timer = null; },
143
+ idle: () => Promise.all([...busy.values()]),
144
+ };
145
+ }
@@ -6,13 +6,15 @@ import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { readCliDefaults } from './cliDefaults.js';
8
8
  import { CloudAuth } from './cloudAuth.js';
9
+ import { hubHooks, postgresHeadroom } from './doctorWarnings.js';
9
10
  import { createClaudeDriver, createCursorDriver } from './drivers.js';
10
- import { HubError } from './errors.js';
11
+ import { HubError, SCOPE_FIX } from './errors.js';
11
12
  import { fetchJson } from './http.js';
12
13
  import { loadIdentity, loadOrCreateIdentity } from './identity.js';
13
14
  import { MIRROR_SCOPE } from './mirrorContract.js';
14
15
  import { localScopeCheckApplies, MIRROR_STATE_FILE, mirrorEnabled } from './mirrorDaemon.js';
15
16
  import { MirrorSync, requestResend } from './mirrorSync.js';
17
+ import { assertNodeFloor, nodeFloor } from './nodeFloor.js';
16
18
  import { loginWithBrowser } from './oauthLogin.js';
17
19
  import { assertPairedConfig, pairedStatus } from './pairedConfig.js';
18
20
  import { pairConnector } from './pairingLifecycle.js';
@@ -20,13 +22,14 @@ import { ProcessSupervisor } from './processSupervisor.js';
20
22
  import { defaultSecretStore } from './secretStore.js';
21
23
  import { consumeRestartRequest, RESTART_REQUEST_FILE } from './selfRestart.js';
22
24
  import { ServiceManager } from './serviceManager.js';
25
+ import { importEnabled } from './shadowService.js';
23
26
  import {
24
27
  ensureOwnerOnlyDirectory,
25
28
  readOwnerOnlyJson,
26
29
  writeOwnerOnlyJson,
27
30
  } from './storage.js';
28
31
  import { TokenStore } from './tokenStore.js';
29
- import { USER_API_BASE } from './transportContract.js';
32
+ import { CONNECTOR_SCOPES, USER_API_BASE } from './transportContract.js';
30
33
 
31
34
  const CONFIG_FILE = 'config.json';
32
35
  const HUB_WRITE = 'hub.write';
@@ -37,8 +40,11 @@ const DAEMON_PATH = fileURLToPath(new URL('../daemon/main.js', import.meta.url))
37
40
  const SIGN_IN_HINT = 'Run `agentpulse pulse login`, then `agentpulse pulse start`.';
38
41
  const STALE_SERVICE_MESSAGE = 'The Pulse connector service points at an old daemon path.';
39
42
  const RECONCILE_STALE_MS = 5 * 60_000;
43
+ /** A new pairing reads and writes its owner's Pulse log (hub.sync, log.write); Pulse running hub.* jobs (hub.write) is opt-in. */
44
+ export const DEFAULT_PAIRING_CAPABILITIES = Object.freeze(['workspace.read', 'git.read', 'hub.sync', 'log.write']);
45
+ const LOG_CAPABILITIES = Object.freeze(['hub.sync', 'log.write']);
40
46
  const DEFAULT_PAIRING_POLICY = Object.freeze({
41
- capabilities: Object.freeze(['workspace.read', 'git.read']),
47
+ capabilities: DEFAULT_PAIRING_CAPABILITIES,
42
48
  providerPolicy: Object.freeze({
43
49
  providers: Object.freeze({}),
44
50
  maxConcurrency: 1,
@@ -71,6 +77,14 @@ async function openInBrowser(url) {
71
77
  }
72
78
 
73
79
  const BUSY_STATES = new Set(['running', 'idle_unpaired', 'starting']);
80
+ /** SCOPE-02: what reading and writing the Pulse log needs from the sign-in; `pulse start` checks it first. */
81
+ const LOG_SCOPES = Object.freeze(CONNECTOR_SCOPES.filter((scope) => scope === 'agent-hub:connector:sync' || scope === 'agent-hub:connector:commands'));
82
+
83
+ /** The Pulse log scopes a sign-in lacks, when this machine uses the log (import, shadow or runner) on Pulse itself. */
84
+ export function missingLogScopes(config, granted) {
85
+ if (!localScopeCheckApplies(config) || !(importEnabled(config) || config?.cloudFirst?.mode === 'runner')) return [];
86
+ return LOG_SCOPES.filter((scope) => !granted.includes(scope));
87
+ }
74
88
 
75
89
  /** What the daemon's last heartbeat says beyond "running": not taking work, jobs being recovered, settlements given up. */
76
90
  function healthSummary(health) {
@@ -93,6 +107,10 @@ function healthSummary(health) {
93
107
  for (const job of health.settleFailed || []) {
94
108
  summary += ` Gave up settling job ${job.jobId} (${job.code}); the cloud will expire it as connector_no_result.`;
95
109
  }
110
+ const sync = health.runner?.runner;
111
+ if (sync?.state === 'blocked') summary += ` The runner is blocked: ${sync.reason}. Run \`${sync.fix}\`.`;
112
+ else if (sync?.state === 'error') summary += ` The runner stopped syncing: ${sync.reason} (applied ${sync.applied} of ${sync.head}).`;
113
+ else if (sync?.reason?.startsWith('sync failing')) summary += ` The runner is ${sync.state}: ${sync.reason}, ${sync.failures} failures in a row.`;
96
114
  return summary;
97
115
  }
98
116
 
@@ -103,6 +121,7 @@ export class ConnectorApplication {
103
121
  serviceManager = new ServiceManager({ daemonPath: DAEMON_PATH, configDir }),
104
122
  pairingPolicy = DEFAULT_PAIRING_POLICY,
105
123
  providerProbes = null,
124
+ doctorWarnings = { postgres: postgresHeadroom, hooks: hubHooks },
106
125
  fetch = globalThis.fetch,
107
126
  login = null,
108
127
  cliDefaults = readCliDefaults,
@@ -110,7 +129,9 @@ export class ConnectorApplication {
110
129
  log = (line) => process.stderr.write(`${line}\n`),
111
130
  startHealthTimeoutMs = 10_000,
112
131
  restartTimeoutMs = 60_000,
132
+ nodeVersion = process.versions.node,
113
133
  } = {}) {
134
+ this.nodeVersion = nodeVersion;
114
135
  this.startHealthTimeoutMs = startHealthTimeoutMs;
115
136
  this.restartTimeoutMs = restartTimeoutMs;
116
137
  this.configDir = configDir;
@@ -118,6 +139,7 @@ export class ConnectorApplication {
118
139
  this.serviceManager = serviceManager;
119
140
  this.pairingPolicy = structuredClone(pairingPolicy);
120
141
  this.providerProbes = providerProbes || this.#defaultProviderProbes.bind(this);
142
+ this.doctorWarnings = doctorWarnings;
121
143
  this.fetch = fetch;
122
144
  this.browserLogin = login || ((options) => loginWithBrowser({
123
145
  ...options,
@@ -236,12 +258,19 @@ export class ConnectorApplication {
236
258
  }
237
259
 
238
260
  async start() {
261
+ assertNodeFloor(this.nodeVersion);
239
262
  const config = await this.#config();
240
263
  const servicePath = await this.#servicePath();
241
264
  if (!servicePath.ok) throw new HubError(servicePath.code, `${STALE_SERVICE_MESSAGE} ${servicePath.hint}`);
242
265
  assertPairedConfig(config);
243
266
  const auth = await this.#authStatus();
244
267
  if (!auth.ok) throw new HubError('HUB_LOGIN_REQUIRED', `Pulse connector is not signed in. ${SIGN_IN_HINT}`);
268
+ const missingScopes = missingLogScopes(config, await this.tokenStore.scopes());
269
+ if (missingScopes.length) {
270
+ throw new HubError('HUB_SCOPE_MISSING', `The Pulse sign-in lacks ${missingScopes.join(', ')}, which the Pulse log needs. Run \`${SCOPE_FIX}\`, then start again.`, {
271
+ details: { missingScopes, fix: SCOPE_FIX },
272
+ });
273
+ }
245
274
  const requestedAt = Date.now();
246
275
  const service = await this.serviceManager.start();
247
276
  let daemonState = null;
@@ -372,7 +401,7 @@ export class ConnectorApplication {
372
401
  }
373
402
 
374
403
  async doctor() {
375
- const checks = {};
404
+ const checks = { node: nodeFloor(this.nodeVersion) };
376
405
  const config = await this.#config(false);
377
406
  try {
378
407
  const directory = await lstat(this.configDir);
@@ -399,7 +428,13 @@ export class ConnectorApplication {
399
428
  checks.providers = await this.providerProbes();
400
429
  checks.pairing = pairedStatus(config);
401
430
  checks.auth = config ? await this.#authStatus() : { ok: false, code: 'HUB_NOT_INSTALLED' };
431
+ // Warnings only: they are reported, and never make the installation unhealthy.
432
+ const [postgres, hooks] = await Promise.all([this.doctorWarnings.postgres(), this.doctorWarnings.hooks()]);
433
+ checks.postgres = postgres;
434
+ checks.hooks = hooks;
435
+ const warnings = [postgres, hooks].filter((c) => c.warn).map((c) => c.message);
402
436
  const ok =
437
+ checks.node.ok &&
403
438
  checks.storage.ok &&
404
439
  checks.identity.ok &&
405
440
  checks.service.ok &&
@@ -411,9 +446,10 @@ export class ConnectorApplication {
411
446
  state: ok ? 'healthy' : 'unhealthy',
412
447
  ok,
413
448
  checks,
449
+ warnings,
414
450
  message: ok
415
451
  ? 'Pulse local installation, pairing and sign-in are healthy.'
416
- : 'Pulse local installation has failed checks.',
452
+ : checks.node.ok ? 'Pulse local installation has failed checks.' : checks.node.message,
417
453
  };
418
454
  }
419
455
 
@@ -521,6 +557,38 @@ export class ConnectorApplication {
521
557
  }
522
558
 
523
559
  async hubWrite({ action }) {
560
+ return this.#grantToggle({
561
+ action, command: 'hub-write', add: [HUB_WRITE], remove: [HUB_WRITE],
562
+ messages: {
563
+ status: (on) => (on
564
+ ? 'Pulse may post to the local Agent Hub as you (hub.write is granted).'
565
+ : 'Pulse cannot post to the local Agent Hub (hub.write is off). Turn it on with `agentpulse pulse hub-write on`.'),
566
+ same: (on) => `hub.write is already ${on ? 'on' : 'off'}.`,
567
+ changed: (on) => (on
568
+ ? 'hub.write is on: Pulse can post, nudge, decide and accept on the local Agent Hub as you. The daemon picks up the new grant within a heartbeat.'
569
+ : 'hub.write is off: Pulse can no longer post to the local Agent Hub. Actions still queued for this machine expire.'),
570
+ },
571
+ });
572
+ }
573
+
574
+ /** The machine's access to its owner's Pulse log: hub.sync to read it, log.write to write it (apart from hub.write). */
575
+ async logWrite({ action }) {
576
+ return this.#grantToggle({
577
+ action, command: 'log-write', add: LOG_CAPABILITIES, remove: ['log.write'],
578
+ messages: {
579
+ status: (on) => (on
580
+ ? 'This machine reads and writes your Pulse log (hub.sync and log.write are granted).'
581
+ : 'This machine cannot write your Pulse log (log.write is off). Turn it on with `agentpulse pulse log-write on`.'),
582
+ same: (on) => `log.write is already ${on ? 'on' : 'off'}.`,
583
+ changed: (on) => (on
584
+ ? 'log.write is on: this machine reads and writes your Pulse log. The daemon picks up the new grant within a heartbeat.'
585
+ : 'log.write is off: this machine can still read your Pulse log, but no longer writes to it.'),
586
+ },
587
+ });
588
+ }
589
+
590
+ /** Grant or withdraw capabilities through the replace-grant path, and adopt the new grant version. */
591
+ async #grantToggle({ action, command, add, remove, messages }) {
524
592
  const config = await this.#config();
525
593
  if (!config.paired) {
526
594
  throw new HubError('HUB_NOT_PAIRED', 'This machine is not paired; run `agentpulse pulse pair` first.');
@@ -545,33 +613,21 @@ export class ConnectorApplication {
545
613
  if (!grant || grant.state !== 'active' || !Array.isArray(grant.capabilities)) {
546
614
  throw new HubError('HUB_GRANT_MISSING', 'This machine has no active grant in Pulse; pair it again.');
547
615
  }
548
- const granted = grant.capabilities.includes(HUB_WRITE);
616
+ const granted = add.every((capability) => grant.capabilities.includes(capability));
549
617
  if (action === 'status') {
550
- return {
551
- command: 'hub-write',
552
- enabled: granted,
553
- policyVersion: grant.version,
554
- message: granted
555
- ? 'Pulse may post to the local Agent Hub as you (hub.write is granted).'
556
- : 'Pulse cannot post to the local Agent Hub (hub.write is off). Turn it on with `agentpulse pulse hub-write on`.',
557
- };
618
+ return { command, enabled: granted, policyVersion: grant.version, message: messages.status(granted) };
558
619
  }
559
620
  if (action !== 'on' && action !== 'off') {
560
- throw new HubError('HUB_WRITE_ACTION_INVALID', `Unknown hub-write action: ${action}`);
621
+ throw new HubError(command === 'hub-write' ? 'HUB_WRITE_ACTION_INVALID' : 'HUB_GRANT_ACTION_INVALID', `Unknown ${command} action: ${action}`);
561
622
  }
562
623
  const enable = action === 'on';
563
- if (enable === granted) {
564
- return {
565
- command: 'hub-write',
566
- enabled: granted,
567
- policyVersion: grant.version,
568
- changed: false,
569
- message: `hub.write is already ${granted ? 'on' : 'off'}.`,
570
- };
624
+ const has = enable ? granted : remove.some((capability) => grant.capabilities.includes(capability));
625
+ if (enable ? has : !has) {
626
+ return { command, enabled: granted, policyVersion: grant.version, changed: false, message: messages.same(granted) };
571
627
  }
572
628
  const capabilities = enable
573
- ? [...grant.capabilities, HUB_WRITE]
574
- : grant.capabilities.filter((capability) => capability !== HUB_WRITE);
629
+ ? [...grant.capabilities, ...add.filter((capability) => !grant.capabilities.includes(capability))]
630
+ : grant.capabilities.filter((capability) => !remove.includes(capability));
575
631
  const replaced = await fetchJson({
576
632
  fetch: this.fetch,
577
633
  method: 'PUT',
@@ -594,15 +650,7 @@ export class ConnectorApplication {
594
650
  capabilities,
595
651
  paired: { ...config.paired, policyVersion: version },
596
652
  });
597
- return {
598
- command: 'hub-write',
599
- enabled: enable,
600
- policyVersion: version,
601
- changed: true,
602
- message: enable
603
- ? 'hub.write is on: Pulse can post, nudge, decide and accept on the local Agent Hub as you. The daemon picks up the new grant within a heartbeat.'
604
- : 'hub.write is off: Pulse can no longer post to the local Agent Hub. Actions still queued for this machine expire.',
605
- };
653
+ return { command, enabled: enable, policyVersion: version, changed: true, message: messages.changed(enable) };
606
654
  }
607
655
 
608
656
  async stop({ revoke = false } = {}) {
@@ -0,0 +1,99 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ // Ported from agents-hub lib/channels.mjs at 37a0b2f: the post header, role aliases, the header type and the walk that
5
+ // dates headers (which carry a time but no date) backwards from the file's modified time.
6
+ export const HEADER = /^## \[([A-Z][A-Z0-9 -]*?)\] (\d{1,2}):(\d{2})(?::(\d{2}))?(?: (IST|UTC|GMT))? ([^\n]*)$/;
7
+ const ROLE_ALIASES = { 'PLATFORM-AUDITOR': 'PLATFORM AUDITOR', AUDITOR: 'PLATFORM AUDITOR' };
8
+ export const normRole = (role) => {
9
+ const upper = String(role || '').toUpperCase().trim();
10
+ return ROLE_ALIASES[upper] || upper;
11
+ };
12
+ const DAY_S = 86400;
13
+ const DAY_JUMP_S = 2 * 3600;
14
+ const NAME = /^[A-Za-z0-9._-]+$/;
15
+
16
+ /**
17
+ * Every task channel the local hub shows: <root>/<repo>/.agents/<task>/channel.md, roots in the hub's order. A
18
+ * `<repo>/<task>` present under two roots is the first root's (as the hub resolves it); the others are returned in
19
+ * `collisions`. Names outside the channel key charset are returned in `unnamed`.
20
+ */
21
+ export async function channelFiles(roots) {
22
+ const files = [];
23
+ const collisions = [];
24
+ const unnamed = [];
25
+ const seen = new Map();
26
+ for (const root of roots) {
27
+ let repos = [];
28
+ try {
29
+ repos = (await readdir(root)).sort();
30
+ } catch {
31
+ continue;
32
+ }
33
+ for (const repo of repos) {
34
+ const dir = path.join(root, repo, '.agents');
35
+ let tasks = [];
36
+ try {
37
+ tasks = (await readdir(dir)).sort();
38
+ } catch {
39
+ continue;
40
+ }
41
+ for (const task of tasks) {
42
+ const file = path.join(dir, task, 'channel.md');
43
+ let info;
44
+ try {
45
+ info = await stat(file);
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (!info.isFile()) continue;
50
+ const key = `${repo}/${task}`;
51
+ if (!NAME.test(repo) || !NAME.test(task)) {
52
+ unnamed.push({ key, file });
53
+ continue;
54
+ }
55
+ if (seen.has(key)) {
56
+ collisions.push({ key, file, kept: seen.get(key) });
57
+ continue;
58
+ }
59
+ seen.set(key, file);
60
+ files.push({ key, repo, task, file, mtimeMs: info.mtimeMs, size: info.size });
61
+ }
62
+ }
63
+ }
64
+ return { files, collisions, unnamed };
65
+ }
66
+
67
+ export const readChannel = (file) => readFile(file, 'utf8');
68
+
69
+ /**
70
+ * The posts of a channel file as whole blocks: each header line and every line up to the next header, with no line or
71
+ * character cap (the hub's parsePosts caps a body; an import must not). Text before the first header is not a post.
72
+ * `at` is each header's time on the date the backwards walk gives it.
73
+ */
74
+ export function splitPosts(text, mtimeMs, utcOffsetS = -new Date(mtimeMs).getTimezoneOffset() * 60) {
75
+ const lines = String(text).replace(/\r/g, '').split('\n');
76
+ const raw = [];
77
+ let current = null;
78
+ for (const line of lines) {
79
+ const match = line.match(HEADER);
80
+ if (match) {
81
+ const head = match[6].trim();
82
+ let sec = Number(match[2]) * 3600 + Number(match[3]) * 60 + Number(match[4] || 0) + (match[5] === 'UTC' || match[5] === 'GMT' ? utcOffsetS : 0);
83
+ sec -= Math.floor(sec / DAY_S) * DAY_S;
84
+ current = { role: normRole(match[1]), head, type: head.split(/ [—(-] | — /)[0].trim().slice(0, 40), sec, lines: [line] };
85
+ raw.push(current);
86
+ } else if (current) {
87
+ current.lines.push(line);
88
+ }
89
+ }
90
+ const mt = new Date(mtimeMs);
91
+ const day = new Date(mt.getFullYear(), mt.getMonth(), mt.getDate());
92
+ let later = mt.getHours() * 3600 + mt.getMinutes() * 60 + mt.getSeconds() + 600;
93
+ for (let k = raw.length - 1; k >= 0; k -= 1) {
94
+ if (raw[k].sec > later + DAY_JUMP_S) day.setDate(day.getDate() - 1);
95
+ later = raw[k].sec;
96
+ raw[k].at = day.getTime() + raw[k].sec * 1000;
97
+ }
98
+ return raw.map(({ role, head, type, at, lines: block }) => ({ role, head, type, at, text: block.join('\n').trimEnd() }));
99
+ }
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, copyFile, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { HubError } from './errors.js';
6
+
7
+ const CLIENTS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'clients');
8
+ const MARKER = '/*PULSE_CLIENT_CONFIG*/ null';
9
+
10
+ /**
11
+ * The agents-hub entry points every agent and hook already calls, with their git blob at the frozen 6b2fb0b. Cutover
12
+ * replaces each with the Pulse thin client, keeping the original beside it; rollback puts the original back.
13
+ */
14
+ export const ENTRY_POINTS = Object.freeze([
15
+ { name: 'hub.mjs', client: 'hub.mjs', relative: 'hub.mjs', blob: 'f83f19a2bb6ed6236c48ed219e33a54998358731' },
16
+ { name: 'hook', client: 'hub-bridge.mjs', relative: path.join('hooks', 'hub-bridge.mjs'), blob: '3caacb1771e5efa0f354f9a0757c3c6f3561acae' },
17
+ ]);
18
+ export const ORIGINAL_SUFFIX = '.pre-cutover';
19
+
20
+ export const gitBlobSha = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
21
+
22
+ /** A thin client with its configuration written in: `{ port, tokenFile, spoolDir, cliPath, offline }`. */
23
+ export async function renderClient(client, config) {
24
+ const source = await readFile(path.join(CLIENTS, client), 'utf8');
25
+ if (source.split(MARKER).length !== 2) throw new HubError('HUB_CLIENT_TEMPLATE', `${client} has no configuration marker`);
26
+ return source.replace(MARKER, () => JSON.stringify(config));
27
+ }
28
+
29
+ export function clientConfig({ hubHome, port = 8890, offline = false }) {
30
+ return {
31
+ port,
32
+ tokenFile: path.join(hubHome, 'state', 'hub.token'),
33
+ spoolDir: path.join(hubHome, 'state', 'spool'),
34
+ cliPath: path.join(hubHome, 'hub.mjs'),
35
+ offline,
36
+ };
37
+ }
38
+
39
+ async function atomicWrite(file, text, mode) {
40
+ const temp = `${file}.pulse-${process.pid}.tmp`;
41
+ await writeFile(temp, text, { mode });
42
+ await chmod(temp, mode);
43
+ await rename(temp, file);
44
+ }
45
+
46
+ async function readOrNull(file) {
47
+ try {
48
+ return await readFile(file);
49
+ } catch (error) {
50
+ if (error.code === 'ENOENT') return null;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Install the thin clients over the entry points, online or offline. The first install keeps each original as
57
+ * `<file>.pre-cutover` and refuses unless it is exactly the 6b2fb0b blob; a later call only switches the client.
58
+ */
59
+ export async function installClients({ hubHome, port = 8890, offline = false }) {
60
+ const config = clientConfig({ hubHome, port, offline });
61
+ const installed = [];
62
+ for (const entry of ENTRY_POINTS) {
63
+ const file = path.join(hubHome, entry.relative);
64
+ const original = `${file}${ORIGINAL_SUFFIX}`;
65
+ if (!(await readOrNull(original))) {
66
+ const current = await readOrNull(file);
67
+ if (!current) throw new HubError('HUB_ENTRY_POINT_MISSING', `${file} is missing`);
68
+ if (gitBlobSha(current) !== entry.blob) {
69
+ throw new HubError('HUB_ENTRY_POINT_CHANGED', `${file} is not agents-hub 6b2fb0b; cutover will not replace it`, { details: { file, blob: gitBlobSha(current) } });
70
+ }
71
+ await copyFile(file, original);
72
+ await chmod(original, 0o444);
73
+ }
74
+ await atomicWrite(file, await renderClient(entry.client, config), 0o755);
75
+ installed.push({ name: entry.name, file, offline });
76
+ }
77
+ return installed;
78
+ }
79
+
80
+ /** Put the 6b2fb0b entry points back, byte for byte, and drop the kept copies. */
81
+ export async function restoreClients({ hubHome }) {
82
+ const restored = [];
83
+ for (const entry of ENTRY_POINTS) {
84
+ const file = path.join(hubHome, entry.relative);
85
+ const original = `${file}${ORIGINAL_SUFFIX}`;
86
+ const kept = await readOrNull(original);
87
+ if (!kept) {
88
+ const current = await readOrNull(file);
89
+ if (current && gitBlobSha(current) === entry.blob) { restored.push({ name: entry.name, file, already: true }); continue; }
90
+ throw new HubError('HUB_ENTRY_POINT_LOST', `${original} is missing, and ${file} is not the original`);
91
+ }
92
+ if (gitBlobSha(kept) !== entry.blob) throw new HubError('HUB_ENTRY_POINT_CHANGED', `${original} is not agents-hub 6b2fb0b`);
93
+ await chmod(original, 0o644);
94
+ await rename(original, file);
95
+ restored.push({ name: entry.name, file });
96
+ }
97
+ return restored;
98
+ }
99
+
100
+ /** Which entry points are the thin client now, and whether it is offline. */
101
+ export async function clientState({ hubHome }) {
102
+ const out = {};
103
+ for (const entry of ENTRY_POINTS) {
104
+ const current = await readOrNull(path.join(hubHome, entry.relative));
105
+ const text = current ? current.toString('utf8') : '';
106
+ out[entry.name] = !current ? 'missing'
107
+ : gitBlobSha(current) === entry.blob ? 'original'
108
+ : /"offline":true/.test(text) ? 'client-offline' : /"offline":false/.test(text) ? 'client-online' : 'changed';
109
+ }
110
+ return out;
111
+ }