@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.
- package/README.md +46 -0
- package/package.json +7 -4
- 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 +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +19 -12
- 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/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -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/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +61 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -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
|
@@ -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
|
+
}
|
package/src/lib/application.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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,
|
|
574
|
-
: grant.capabilities.filter((capability) => capability
|
|
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
|
+
}
|