@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,147 @@
|
|
|
1
|
+
// agents-hub lib/status.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// A status you set by hand on a delivery or an agent: stored once as the registry fact `status_override`, applied over
|
|
3
|
+
// the derived status when the hub projects its rows, and recorded in the task channels as a USER post. Pure.
|
|
4
|
+
|
|
5
|
+
export const USER_STATUSES = ['done', 'blocked', 'stopped', 'handed-over', 'working'];
|
|
6
|
+
export const STATUS_LIMITS = Object.freeze({ id: 100, reason: 500, to: 120, key: 240 });
|
|
7
|
+
/** The Board's step columns (channels.STEPS), and the ids a move names them by. */
|
|
8
|
+
const STEP_NAMES = ['Research', 'Design', 'Mockup', 'Build', 'Review', 'Checks', 'Proof'];
|
|
9
|
+
export const STEP_IDS = STEP_NAMES.map((name) => name.toLowerCase());
|
|
10
|
+
const STREAM_KEY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
11
|
+
/** Done and Stopped hold until you change them; the others give way to the next agent header in the channel. */
|
|
12
|
+
const STICKY = new Set(['done', 'stopped']);
|
|
13
|
+
const HEADER_TYPE = { done: 'DONE', blocked: 'BLOCKER', stopped: 'STOPPED', 'handed-over': 'HANDED OVER', working: 'STATUS' };
|
|
14
|
+
const LABEL = { done: 'Done', blocked: 'Blocked', stopped: 'Stopped', 'handed-over': 'Handed over', working: 'Working' };
|
|
15
|
+
const DELIVERY_STATUS = { done: 'done', stopped: 'done', blocked: 'blocked', 'handed-over': 'on-track', working: 'on-track' };
|
|
16
|
+
const AGENT_STATUS = { done: 'done', stopped: 'done', blocked: 'stuck', 'handed-over': 'waiting', working: 'working' };
|
|
17
|
+
const LOCAL_ID = /^[^\0-\x1f\x7f]+$/;
|
|
18
|
+
|
|
19
|
+
/** One line, no control or bidi characters: the reason goes into a channel header that editors also render. */
|
|
20
|
+
const oneLine = (s) => s.replace(/[\0-\x1f\x7f-\x9f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
21
|
+
|
|
22
|
+
function text(v, name, max, required) {
|
|
23
|
+
if (v === undefined || v === null) return required ? { error: `${name} is required` } : { value: null };
|
|
24
|
+
if (typeof v !== 'string') return { error: `${name} must be text` };
|
|
25
|
+
const value = oneLine(v);
|
|
26
|
+
if (!value) return required ? { error: `${name} is required` } : { value: null };
|
|
27
|
+
if (value.length > max) return { error: `${name} must be at most ${max} characters` };
|
|
28
|
+
return { value };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A POST /api/status body → { target, status, reason, to }, or for a stream (a card moved to a step column)
|
|
33
|
+
* { target, step }; or { error }. The hub works out the rest of a move itself, so a move carries nothing else.
|
|
34
|
+
*/
|
|
35
|
+
export function statusRequest(body) {
|
|
36
|
+
const t = body && body.target;
|
|
37
|
+
if (!t || typeof t !== 'object' || !['delivery', 'agent', 'stream'].includes(t.kind)) return { error: 'target must be a delivery, an agent or a stream' };
|
|
38
|
+
if (t.kind === 'stream') {
|
|
39
|
+
if (typeof t.id !== 'string' || t.id.length > STATUS_LIMITS.key || !STREAM_KEY.test(t.id)) return { error: 'target id must be a <repo>/<channel> stream key' };
|
|
40
|
+
if (!STEP_IDS.includes(body.step)) return { error: `step must be one of ${STEP_IDS.join(', ')}` };
|
|
41
|
+
const extra = Object.keys(body).find((k) => !['target', 'step'].includes(k));
|
|
42
|
+
if (extra) return { error: `${extra} is not part of a move` };
|
|
43
|
+
return { target: { kind: 'stream', id: t.id }, step: body.step };
|
|
44
|
+
}
|
|
45
|
+
if (body.step !== undefined) return { error: 'step is only for a stream' };
|
|
46
|
+
if (typeof t.id !== 'string' || !t.id || t.id.length > STATUS_LIMITS.id || !LOCAL_ID.test(t.id)) return { error: 'invalid target id' };
|
|
47
|
+
if (!USER_STATUSES.includes(body.status)) return { error: `status must be one of ${USER_STATUSES.join(', ')}` };
|
|
48
|
+
const reason = text(body.reason, 'reason', STATUS_LIMITS.reason, body.status === 'blocked');
|
|
49
|
+
if (reason.error) return { error: reason.error };
|
|
50
|
+
if (body.status !== 'handed-over' && body.to !== undefined && body.to !== null) return { error: 'to is only for handed-over' };
|
|
51
|
+
const to = body.status === 'handed-over' ? text(body.to, 'to', STATUS_LIMITS.to, true) : { value: null };
|
|
52
|
+
if (to.error) return { error: to.error };
|
|
53
|
+
return { target: { kind: t.kind, id: t.id }, status: body.status, reason: reason.value, to: to.value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const overrideKey = (target) => `${target.kind}:${target.id}`;
|
|
57
|
+
|
|
58
|
+
/** Header text without `#`: the parser reads "#12" in any header as a finding id that a closing word would close. */
|
|
59
|
+
const headerSafe = (s) => oneLine(s.replace(/#/g, ''));
|
|
60
|
+
|
|
61
|
+
/** The channel record of a status you set: one USER header, in the time format decisionPost writes. */
|
|
62
|
+
const clock = (at) => [at.getHours(), at.getMinutes(), at.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
|
|
63
|
+
|
|
64
|
+
export function auditPost({ status, reason = null, to = null }, { at = new Date(), who = null } = {}) {
|
|
65
|
+
const hhmmss = clock(at);
|
|
66
|
+
const what = [to ? `to ${headerSafe(to)}` : null, reason ? headerSafe(reason) : null].filter(Boolean).join(': ');
|
|
67
|
+
return `\n## [USER] ${hhmmss} ${HEADER_TYPE[status]} — set from the Board${who ? ` for ${headerSafe(who)}` : ''}${what ? `: ${what}` : ''}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "Blocked, set by you: org down" — the words a row shows for a status you set. */
|
|
71
|
+
export function overrideText(o) {
|
|
72
|
+
const by = !o.by || o.by === 'user' ? 'you' : o.by;
|
|
73
|
+
return `${LABEL[o.status]}${o.to ? ` to ${o.to}` : ''}, set by ${by}${o.reason ? `: ${o.reason}` : ''}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether an agent has posted a header that moves the work (channels.movesWork: not USER posts, so not the audit post,
|
|
78
|
+
* and not check-ins) since status `o` was set. Headers carry agents' clocks, not the hub's, so this goes by place in
|
|
79
|
+
* the channel: `o.seqs` holds the audit post's position in each stream it went to, `seqOf(key)` the newest moving
|
|
80
|
+
* header's. A status set before the hub recorded positions falls back to `agentAt`, the newest such header's time.
|
|
81
|
+
*/
|
|
82
|
+
export function movedSince(o, { agentAt = 0, seqOf = () => 0 } = {}) {
|
|
83
|
+
const seqs = Object.entries(o.seqs || {});
|
|
84
|
+
return seqs.length ? seqs.some(([key, seq]) => seqOf(key) > seq) : agentAt > o.at;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** For movedSince: where the work under a delivery fact (its streams) or an agent-row fact (its own stream) last moved. */
|
|
88
|
+
export function workMoved(kind, row) {
|
|
89
|
+
if (kind === 'delivery') {
|
|
90
|
+
const streams = row.streams || [];
|
|
91
|
+
const at = new Map(streams.map((s) => [s.key, s.agentSeq || 0]));
|
|
92
|
+
return { agentAt: Math.max(0, ...streams.map((s) => s.agentAt || 0)), seqOf: (key) => at.get(key) || 0 };
|
|
93
|
+
}
|
|
94
|
+
return { agentAt: row.movedAt || 0, seqOf: (key) => (key === row.streamKey ? row.movedSeq || 0 : 0) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a status you set still holds: Done and Stopped stick, the soft ones last until the work moves. */
|
|
98
|
+
export const overrideHolds = (o, moved = {}) => !!o && USER_STATUSES.includes(o.status) && (STICKY.has(o.status) || !movedSince(o, moved));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A delivery or agent row with the status you set over its derived one. An open ask stays in front: only Stopped, or
|
|
102
|
+
* Done on anything but a result waiting for your Accept, takes a delivery out of needs-you.
|
|
103
|
+
*/
|
|
104
|
+
export function withOverride(row, o, { kind, ...moved }) {
|
|
105
|
+
if (!overrideHolds(o, moved)) return row;
|
|
106
|
+
const override = { status: o.status, reason: o.reason || null, to: o.to || null, by: o.by || 'user', at: o.at };
|
|
107
|
+
const asking = kind === 'delivery' && row.status === 'needs-you';
|
|
108
|
+
if (asking && !(o.status === 'stopped' || (o.status === 'done' && !(row.ask && row.ask.kind === 'result')))) return { ...row, override };
|
|
109
|
+
if (kind === 'agent') {
|
|
110
|
+
return { ...row, status: AGENT_STATUS[o.status], waitingOn: o.status === 'handed-over' ? override.to : row.waitingOn, override };
|
|
111
|
+
}
|
|
112
|
+
return { ...row, status: DELIVERY_STATUS[o.status], reason: overrideText(override), override };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The channel record of a card you moved: a USER NOTE, which is chatter, so it never moves the derived step itself. */
|
|
116
|
+
export const movePost = ({ step }, { at = new Date() } = {}) => `\n## [USER] ${clock(at)} NOTE — moved to ${STEP_NAMES[STEP_IDS.indexOf(step)]} from the Board\n`;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a step you moved a card to still holds on stream `s` (a streamView). It ends at the first header that moves
|
|
120
|
+
* the work, comes after the move's NOTE in the channel (`o.seq`, its position), and either leaves the derived step
|
|
121
|
+
* where it was not when you moved the card (`fromStep`) or implies on its own a step beyond yours (`stepSeq[i]`: the
|
|
122
|
+
* position of the newest such header implying step i or later). So commits in Build keep a move to Review, and the
|
|
123
|
+
* next findings end a move back to Build.
|
|
124
|
+
*/
|
|
125
|
+
export function stepHolds(o, s) {
|
|
126
|
+
if (!o || !STEP_IDS.includes(o.step) || !Number.isInteger(o.seq) || s.done) return false;
|
|
127
|
+
const mine = STEP_IDS.indexOf(o.step);
|
|
128
|
+
if ((s.agentSeq || 0) > o.seq && s.step !== o.fromStep) return false;
|
|
129
|
+
return !(((s.stepSeq || [])[mine + 1] || 0) > o.seq);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A stream row in the column you moved it to, with `override` saying who moved it and when. */
|
|
133
|
+
export function withStep(s, o) {
|
|
134
|
+
if (!stepHolds(o, s)) return s;
|
|
135
|
+
const step = STEP_IDS.indexOf(o.step);
|
|
136
|
+
return { ...s, step, stepName: STEP_NAMES[step], override: { step: o.step, by: o.by || 'user', at: o.at } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A delivery row with its streams' moves applied; its own step and counts follow them, as buildDeliveries counts. */
|
|
140
|
+
export function withSteps(d, overrideOf) {
|
|
141
|
+
const streams = (d.streams || []).map((s) => withStep(s, overrideOf(s.key)));
|
|
142
|
+
if (streams.every((s, i) => s === d.streams[i])) return d;
|
|
143
|
+
const open = streams.filter((s) => !s.done);
|
|
144
|
+
const stepCounts = STEP_NAMES.map((_, i) => open.filter((s) => s.step === i).length);
|
|
145
|
+
const step = open.length ? stepCounts.lastIndexOf(Math.max(...stepCounts)) : d.step;
|
|
146
|
+
return { ...d, streams, stepCounts, step, stepName: step == null ? null : STEP_NAMES[step] };
|
|
147
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// agents-hub lib/store.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// The hub's files. Everything is append-only JSONL or one small JSON file, so any sandboxed session can write it;
|
|
3
|
+
// for the same reason everything read back is shape-checked before use.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { hubHome } from '../home.mjs';
|
|
7
|
+
|
|
8
|
+
export const HUB = hubHome();
|
|
9
|
+
const STATE = path.join(HUB, 'state');
|
|
10
|
+
const EVENTS = path.join(STATE, 'events.jsonl');
|
|
11
|
+
const REQUESTS = path.join(STATE, 'requests');
|
|
12
|
+
const SETTINGS = path.join(STATE, 'settings.json');
|
|
13
|
+
export const KEEPAWAKE_STATUS = path.join(STATE, 'keepawake-status.json');
|
|
14
|
+
const EVENTS_TAIL_BYTES = 1024 * 1024;
|
|
15
|
+
const NUDGE_CHOICES = [0, 5, 10, 15, 30];
|
|
16
|
+
const KEEP_AWAKE_MODES = ['off', 'agents', 'always'];
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SETTINGS = { keepAwake: { mode: 'off' }, wake: { nudgeEveryMin: 0, macWakeAt: null } };
|
|
19
|
+
|
|
20
|
+
fs.mkdirSync(REQUESTS, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const readJson = (f, fallback) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; } };
|
|
23
|
+
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
|
24
|
+
const isEvent = (e) => e && typeof e === 'object' && Number.isFinite(e.at) && typeof e.from === 'string' && typeof e.type === 'string';
|
|
25
|
+
|
|
26
|
+
export function appendEvent(e) {
|
|
27
|
+
const event = { at: Date.now(), ...e };
|
|
28
|
+
fs.appendFileSync(EVENTS, JSON.stringify(event) + '\n');
|
|
29
|
+
return event;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Events since sinceMs, from the last megabyte of the log (older ones are history the views never show). */
|
|
33
|
+
export function readEvents(sinceMs = 0) {
|
|
34
|
+
let text = '';
|
|
35
|
+
try {
|
|
36
|
+
const st = fs.statSync(EVENTS);
|
|
37
|
+
const len = Math.min(EVENTS_TAIL_BYTES, st.size);
|
|
38
|
+
const fd = fs.openSync(EVENTS, 'r');
|
|
39
|
+
const buf = Buffer.alloc(len);
|
|
40
|
+
fs.readSync(fd, buf, 0, len, st.size - len);
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
text = buf.toString('utf8');
|
|
43
|
+
if (len < st.size) text = text.slice(text.indexOf('\n') + 1);
|
|
44
|
+
} catch { return []; }
|
|
45
|
+
return text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
46
|
+
.filter((e) => isEvent(e) && e.at >= sinceMs)
|
|
47
|
+
.map((e) => ({ ...e, to: typeof e.to === 'string' ? e.to : '*', text: str(e.text, 500) }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function addRequest({ from, kind, title, files = [] }) {
|
|
51
|
+
const id = `r-${Date.now().toString(36)}`;
|
|
52
|
+
const req = { id, from: [String(from)], kind: String(kind), title: String(title), files, status: 'queued', at: Date.now() };
|
|
53
|
+
fs.writeFileSync(path.join(REQUESTS, `${id}.json`), JSON.stringify(req, null, 2));
|
|
54
|
+
appendEvent({ from: String(from), to: 'manager', type: 'REQUEST', text: `${kind}: ${title}`, ref: id });
|
|
55
|
+
return req;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readRequests() {
|
|
59
|
+
let files = [];
|
|
60
|
+
try { files = fs.readdirSync(REQUESTS).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
61
|
+
return files.map((f) => readJson(path.join(REQUESTS, f), null))
|
|
62
|
+
.filter((r) => r && typeof r === 'object' && Number.isFinite(r.at) && typeof r.title === 'string')
|
|
63
|
+
.map((r) => ({ id: str(r.id, 60), title: str(r.title, 200), kind: str(r.kind, 30), status: str(r.status, 20) || 'queued', at: r.at,
|
|
64
|
+
from: (Array.isArray(r.from) ? r.from : [r.from]).filter((x) => typeof x === 'string').slice(0, 10) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ACCEPTED = path.join(STATE, 'accepted.json');
|
|
68
|
+
|
|
69
|
+
/** Deliveries whose result you accepted, by delivery id → when. Kept in its own file so it never rolls off a log. */
|
|
70
|
+
export function readAccepted() {
|
|
71
|
+
const a = readJson(ACCEPTED, {}) || {};
|
|
72
|
+
return new Map(Object.entries(a).filter(([k, v]) => typeof k === 'string' && Number.isFinite(v)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function acceptDelivery(id) {
|
|
76
|
+
const a = Object.fromEntries(readAccepted());
|
|
77
|
+
a[id] = Date.now();
|
|
78
|
+
fs.writeFileSync(ACCEPTED, JSON.stringify(a, null, 2));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readSettings() {
|
|
82
|
+
const s = readJson(SETTINGS, {}) || {};
|
|
83
|
+
return validSettings({ keepAwake: { ...DEFAULT_SETTINGS.keepAwake, ...(s.keepAwake || {}) }, wake: { ...DEFAULT_SETTINGS.wake, ...(s.wake || {}) } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Only known keys and values survive: a nudge interval from the list, a keep-awake mode, a wake time as HH:MM. */
|
|
87
|
+
export function validSettings(s) {
|
|
88
|
+
const mode = KEEP_AWAKE_MODES.includes(s.keepAwake && s.keepAwake.mode) ? s.keepAwake.mode : 'off';
|
|
89
|
+
const every = NUDGE_CHOICES.includes(Number(s.wake && s.wake.nudgeEveryMin)) ? Number(s.wake.nudgeEveryMin) : 0;
|
|
90
|
+
const at = s.wake && typeof s.wake.macWakeAt === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(s.wake.macWakeAt) ? s.wake.macWakeAt : null;
|
|
91
|
+
return { keepAwake: { mode }, wake: { nudgeEveryMin: every, macWakeAt: at } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeSettings(patch) {
|
|
95
|
+
const cur = readSettings();
|
|
96
|
+
const next = validSettings({ keepAwake: { ...cur.keepAwake, ...((patch && patch.keepAwake) || {}) }, wake: { ...cur.wake, ...((patch && patch.wake) || {}) } });
|
|
97
|
+
fs.writeFileSync(SETTINGS, JSON.stringify(next, null, 2));
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What the keep-awake helper last reported; `installed` is false when it has not checked in for 90 s. */
|
|
102
|
+
export function keepAwakeStatus() {
|
|
103
|
+
const s = readJson(KEEPAWAKE_STATUS, null);
|
|
104
|
+
if (!s || !Number.isFinite(s.heartbeat)) return { installed: false, running: false };
|
|
105
|
+
return { running: !!s.running, since: Number.isFinite(s.since) ? s.since : null, installed: Date.now() - s.heartbeat < 90e3 };
|
|
106
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// agents-hub lib/titles.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// The title Cursor gives each chat (the one in its chat list), read from Cursor's own store so a delivery is named
|
|
3
|
+
// the way you already know it. Read-only; any failure just means no title, and the caller falls back to the prompt.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const DB = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
9
|
+
const SQLITE = '/usr/bin/sqlite3';
|
|
10
|
+
const ID = /^[0-9a-f-]{36}$/;
|
|
11
|
+
const TTL_MS = 60e3;
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
|
|
14
|
+
/** Cursor's chat titles for these conversation ids, as a Map id → title (ids it has no title for are left out). */
|
|
15
|
+
export function chatTitles(ids) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const want = [...new Set(ids)].filter((id) => ID.test(id) && !(cache.has(id) && now - cache.get(id).at < TTL_MS));
|
|
18
|
+
if (want.length) {
|
|
19
|
+
let rows = [];
|
|
20
|
+
try {
|
|
21
|
+
const sql = `SELECT substr(key, 14) AS id, json_extract(value, '$.name') AS name FROM cursorDiskKV WHERE key IN (${want.map((id) => `'composerData:${id}'`).join(',')});`;
|
|
22
|
+
const out = execFileSync(SQLITE, ['-readonly', '-json', `file:${DB}?mode=ro`, sql], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
23
|
+
rows = out.trim() ? JSON.parse(out) : [];
|
|
24
|
+
} catch { rows = []; }
|
|
25
|
+
const got = new Map(rows.filter((r) => typeof r.name === 'string' && r.name.trim()).map((r) => [r.id, r.name.trim().slice(0, 80)]));
|
|
26
|
+
for (const id of want) cache.set(id, { at: now, name: got.get(id) || null });
|
|
27
|
+
}
|
|
28
|
+
return new Map(ids.map((id) => [id, cache.get(id) && cache.get(id).name]).filter(([, name]) => name));
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// agents-hub lib/transcript-feed.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notices Cursor transcript writes within about a second without fs.watch: a recursive watch over ~/.cursor/projects
|
|
7
|
+
* (hundreds of folders) runs out of file handles and then dies silently. Instead, stat the recently active session
|
|
8
|
+
* files every second, and rescan every session every 30 s so a resumed old session or a new project is picked up.
|
|
9
|
+
*/
|
|
10
|
+
const FAST_MS = 1000;
|
|
11
|
+
const FULL_MS = 30e3;
|
|
12
|
+
const ACTIVE_MS = 6 * 3600e3;
|
|
13
|
+
|
|
14
|
+
const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
|
|
15
|
+
const dirs = (dir) => safe(() => fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name), []);
|
|
16
|
+
const jsonl = (dir) => safe(() => fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(dir, f)), []);
|
|
17
|
+
const sig = (file) => safe(() => { const s = fs.statSync(file); return { m: s.mtimeMs, z: s.size }; }, null);
|
|
18
|
+
|
|
19
|
+
function sessionFiles(sessionDir) {
|
|
20
|
+
return [...jsonl(sessionDir), ...jsonl(path.join(sessionDir, 'subagents'))];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function watchTranscripts(projectsDir, onChange) {
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
const activeSessions = new Set();
|
|
26
|
+
const activeRoots = new Set();
|
|
27
|
+
|
|
28
|
+
const check = (file, now, notify) => {
|
|
29
|
+
const s = sig(file);
|
|
30
|
+
if (!s) return false;
|
|
31
|
+
const was = seen.get(file);
|
|
32
|
+
seen.set(file, s);
|
|
33
|
+
if (notify && (!was || was.m !== s.m || was.z !== s.z)) onChange(file);
|
|
34
|
+
return now - s.m < ACTIVE_MS;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const full = (notify) => {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
activeSessions.clear();
|
|
40
|
+
activeRoots.clear();
|
|
41
|
+
for (const project of dirs(projectsDir)) {
|
|
42
|
+
const root = path.join(projectsDir, project, 'agent-transcripts');
|
|
43
|
+
const sessions = dirs(root);
|
|
44
|
+
if (!sessions.length) continue;
|
|
45
|
+
const rootRecent = now - (sig(root)?.m || 0) < ACTIVE_MS;
|
|
46
|
+
let any = false;
|
|
47
|
+
for (const id of sessions) {
|
|
48
|
+
const dir = path.join(root, id);
|
|
49
|
+
let live = false;
|
|
50
|
+
for (const f of sessionFiles(dir)) live = check(f, now, notify) || live;
|
|
51
|
+
if (live) { activeSessions.add(dir); any = true; }
|
|
52
|
+
}
|
|
53
|
+
if (any || rootRecent) activeRoots.add(root);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const fast = () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const root of activeRoots) {
|
|
60
|
+
for (const id of dirs(root)) {
|
|
61
|
+
const dir = path.join(root, id);
|
|
62
|
+
if (!activeSessions.has(dir) && sessionFiles(dir).some((f) => !seen.has(f))) activeSessions.add(dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const dir of activeSessions) for (const f of sessionFiles(dir)) check(f, now, true);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
full(false);
|
|
69
|
+
setInterval(() => safe(fast), FAST_MS).unref();
|
|
70
|
+
setInterval(() => safe(() => full(true)), FULL_MS).unref();
|
|
71
|
+
return { watching: () => ({ sessions: activeSessions.size, projects: activeRoots.size }) };
|
|
72
|
+
}
|