@indigoai-us/hq-cli 5.108.1 → 5.108.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/CHANGELOG.md +77 -0
- package/assets/mesh-daemon/README.md +23 -0
- package/assets/mesh-daemon/launchd.plist.template +33 -0
- package/assets/mesh-daemon/systemd.system.service.template +27 -0
- package/assets/mesh-daemon/systemd.user.service.template +18 -0
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +267 -0
- package/dist/commands/core.js +5 -4
- package/dist/commands/mesh.d.ts +15 -0
- package/dist/commands/mesh.js +732 -4
- package/dist/commands/search.d.ts +23 -0
- package/dist/commands/search.js +42 -1
- package/dist/lib/doctor/checks/work-context.d.ts +23 -0
- package/dist/lib/doctor/checks/work-context.js +195 -0
- package/dist/lib/doctor/registry.js +4 -0
- package/dist/lib/mesh/api.d.ts +18 -0
- package/dist/lib/mesh/api.js +55 -0
- package/dist/lib/mesh/cache.js +35 -4
- package/dist/lib/mesh/client.d.ts +118 -0
- package/dist/lib/mesh/client.js +268 -0
- package/dist/lib/mesh/live/backoff.d.ts +16 -0
- package/dist/lib/mesh/live/backoff.js +20 -0
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
- package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
- package/dist/lib/mesh/live/daemon/credentials.js +227 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/doctor.js +139 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
- package/dist/lib/mesh/live/daemon/index.js +14 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
- package/dist/lib/mesh/live/daemon/install.js +330 -0
- package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
- package/dist/lib/mesh/live/daemon/log.js +79 -0
- package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/paths.js +31 -0
- package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
- package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
- package/dist/lib/mesh/live/daemon/presence.js +250 -0
- package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/presign.js +65 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
- package/dist/lib/mesh/live/daemon/run.js +400 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
- package/dist/lib/mesh/live/daemon/state.js +57 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
- package/dist/lib/mesh/live/enqueue.d.ts +48 -0
- package/dist/lib/mesh/live/enqueue.js +100 -0
- package/dist/lib/mesh/live/flush.d.ts +56 -0
- package/dist/lib/mesh/live/flush.js +360 -0
- package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
- package/dist/lib/mesh/live/format-spool-line.js +106 -0
- package/dist/lib/mesh/live/index.d.ts +22 -0
- package/dist/lib/mesh/live/index.js +16 -0
- package/dist/lib/mesh/live/paths.d.ts +13 -0
- package/dist/lib/mesh/live/paths.js +30 -0
- package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
- package/dist/lib/mesh/live/session-events-client.js +120 -0
- package/dist/lib/mesh/live/session-identity.d.ts +39 -0
- package/dist/lib/mesh/live/session-identity.js +91 -0
- package/dist/lib/mesh/live/spool.d.ts +52 -0
- package/dist/lib/mesh/live/spool.js +193 -0
- package/dist/lib/mesh/live/ulid.d.ts +12 -0
- package/dist/lib/mesh/live/ulid.js +39 -0
- package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
- package/dist/lib/mesh/live/validate-session-event.js +128 -0
- package/dist/lib/search-index/index.d.ts +45 -0
- package/dist/lib/search-index/index.js +76 -0
- package/dist/lib/work-context/atomic.d.ts +18 -0
- package/dist/lib/work-context/atomic.js +132 -0
- package/dist/lib/work-context/company.d.ts +103 -0
- package/dist/lib/work-context/company.js +274 -0
- package/dist/lib/work-context/config.d.ts +59 -0
- package/dist/lib/work-context/config.js +172 -0
- package/dist/lib/work-context/contract.d.ts +81 -0
- package/dist/lib/work-context/contract.js +65 -0
- package/dist/lib/work-context/errors.d.ts +27 -0
- package/dist/lib/work-context/errors.js +46 -0
- package/dist/lib/work-context/index.d.ts +23 -0
- package/dist/lib/work-context/index.js +20 -0
- package/dist/lib/work-context/migrate.d.ts +84 -0
- package/dist/lib/work-context/migrate.js +287 -0
- package/dist/lib/work-context/organize.d.ts +130 -0
- package/dist/lib/work-context/organize.js +471 -0
- package/dist/lib/work-context/outbox.d.ts +97 -0
- package/dist/lib/work-context/outbox.js +357 -0
- package/dist/lib/work-context/paths.d.ts +14 -0
- package/dist/lib/work-context/paths.js +39 -0
- package/dist/lib/work-context/project.d.ts +78 -0
- package/dist/lib/work-context/project.js +247 -0
- package/dist/lib/work-context/reconcile.d.ts +85 -0
- package/dist/lib/work-context/reconcile.js +651 -0
- package/dist/lib/work-context/repo-remote.d.ts +43 -0
- package/dist/lib/work-context/repo-remote.js +200 -0
- package/dist/lib/work-context/state.d.ts +112 -0
- package/dist/lib/work-context/state.js +162 -0
- package/dist/main.js +40 -3
- package/dist/utils/cognito-session.d.ts +85 -9
- package/dist/utils/cognito-session.js +216 -9
- package/dist/utils/qmd-model-download-error.d.ts +27 -0
- package/dist/utils/qmd-model-download-error.js +114 -0
- package/dist/utils/qmd-query-document-error.d.ts +23 -0
- package/dist/utils/qmd-query-document-error.js +117 -0
- package/dist/utils/sentry-fingerprint.js +2 -0
- package/package.json +5 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq mesh daemon run — single-instance resident process.
|
|
3
|
+
*
|
|
4
|
+
* - pid lock at ~/.hq/work-mesh/daemon/pid
|
|
5
|
+
* - fs.watch spool + 2s debounce; flush at least every 10s (US-008 path)
|
|
6
|
+
* - replay context outbox
|
|
7
|
+
* - board.md refresh for bound sessions ≤ every 60s
|
|
8
|
+
* - MQTT presence (contract-3)
|
|
9
|
+
* - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { createWorkSessionDeliverer } from "../../client.js";
|
|
15
|
+
import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
16
|
+
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
17
|
+
import { replayOutbox } from "../../../work-context/outbox.js";
|
|
18
|
+
import { workContextRoot } from "../../../work-context/paths.js";
|
|
19
|
+
import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
|
|
20
|
+
import { flushSessionEvents } from "../flush.js";
|
|
21
|
+
import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
|
|
22
|
+
import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
|
|
23
|
+
import { createContract3Fetcher, realTimerHost, } from "./credentials.js";
|
|
24
|
+
import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
|
|
25
|
+
import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
|
|
26
|
+
import { daemonDir } from "./paths.js";
|
|
27
|
+
import { PresenceClient, } from "./presence.js";
|
|
28
|
+
import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state.js";
|
|
29
|
+
import { workMeshHeldPath } from "../paths.js";
|
|
30
|
+
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
31
|
+
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
32
|
+
export const FLUSH_INTERVAL_MS = 10_000;
|
|
33
|
+
function alive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start the daemon. Resolves once the process is running (lock held, loops armed).
|
|
44
|
+
* Call handle.stop() or send SIGTERM for graceful shutdown.
|
|
45
|
+
*/
|
|
46
|
+
export async function runMeshDaemon(deps = {}) {
|
|
47
|
+
const home = deps.home ?? os.homedir();
|
|
48
|
+
const env = deps.env ?? process.env;
|
|
49
|
+
const meshRoot = deps.meshRoot ?? workMeshRoot(home, env);
|
|
50
|
+
const ctxRoot = deps.workContextRoot ?? workContextRoot(home, env);
|
|
51
|
+
const dir = daemonDir(meshRoot, home, env);
|
|
52
|
+
const timers = deps.timers ?? realTimerHost;
|
|
53
|
+
const now = deps.now ?? (() => new Date());
|
|
54
|
+
const log = deps.log ??
|
|
55
|
+
((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
|
|
56
|
+
const processEvents = deps.processEvents ?? process;
|
|
57
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
ensureDaemonLog(dir);
|
|
60
|
+
const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
|
|
61
|
+
const lock = acquirePidLock(dir, lockDeps);
|
|
62
|
+
if (!lock.ok) {
|
|
63
|
+
throw new Error(`daemon already running (pid ${lock.owner.pid}); refuse second instance`);
|
|
64
|
+
}
|
|
65
|
+
writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
|
|
66
|
+
log(dir, `daemon started pid=${lockDeps.pid}`);
|
|
67
|
+
let token;
|
|
68
|
+
// Opt into machine-credential minting when the box has creds (systemd exports
|
|
69
|
+
// HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
|
|
70
|
+
// through to the person login cache inside ensureCognitoToken.
|
|
71
|
+
const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
|
|
72
|
+
const getToken = deps.getToken ??
|
|
73
|
+
(async () => {
|
|
74
|
+
if (!token)
|
|
75
|
+
token = await defaultGetToken();
|
|
76
|
+
return token;
|
|
77
|
+
});
|
|
78
|
+
const flushFn = deps.flush ??
|
|
79
|
+
(async () => {
|
|
80
|
+
const t = await getToken();
|
|
81
|
+
const poster = createSessionEventsPoster({
|
|
82
|
+
token: t,
|
|
83
|
+
baseUrl: resolveVaultApiBase(env),
|
|
84
|
+
});
|
|
85
|
+
return flushSessionEvents({
|
|
86
|
+
workMeshRoot: meshRoot,
|
|
87
|
+
workContextRoot: ctxRoot,
|
|
88
|
+
poster,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const replayFn = deps.replayOutbox ??
|
|
92
|
+
(async () => {
|
|
93
|
+
const t = await getToken();
|
|
94
|
+
const deliver = createWorkSessionDeliverer({ token: t });
|
|
95
|
+
return replayOutbox(ctxRoot, deliver);
|
|
96
|
+
});
|
|
97
|
+
const boardLast = new Map();
|
|
98
|
+
const refreshFn = deps.refreshBoards ??
|
|
99
|
+
(async () => {
|
|
100
|
+
const t = await getToken();
|
|
101
|
+
const reader = createVaultBoardReader({
|
|
102
|
+
getJson: (p) => meshJson(t, p),
|
|
103
|
+
});
|
|
104
|
+
return refreshBoundSessionBoards({
|
|
105
|
+
workContextRoot: ctxRoot,
|
|
106
|
+
readBoard: reader,
|
|
107
|
+
lastRefresh: boardLast,
|
|
108
|
+
intervalMs: BOARD_REFRESH_INTERVAL_MS,
|
|
109
|
+
now: () => timers.now(),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
let presence = null;
|
|
113
|
+
if (deps.enablePresence !== false) {
|
|
114
|
+
const fetchCredentials = deps.fetchCredentials ??
|
|
115
|
+
(async () => {
|
|
116
|
+
const t = await getToken();
|
|
117
|
+
return createContract3Fetcher({
|
|
118
|
+
token: t,
|
|
119
|
+
baseUrl: resolveVaultApiBase(env),
|
|
120
|
+
env,
|
|
121
|
+
})();
|
|
122
|
+
});
|
|
123
|
+
const presenceOpts = {
|
|
124
|
+
fetchCredentials,
|
|
125
|
+
mqttConnect: deps.mqttConnect,
|
|
126
|
+
timers,
|
|
127
|
+
random: deps.random,
|
|
128
|
+
now,
|
|
129
|
+
onState: (state) => {
|
|
130
|
+
patchDaemonState(dir, { mqttState: state }, now);
|
|
131
|
+
},
|
|
132
|
+
onOnline: (companies) => {
|
|
133
|
+
patchDaemonState(dir, {
|
|
134
|
+
mqttState: "connected",
|
|
135
|
+
companiesOnline: companies,
|
|
136
|
+
actorUid: presence?.getBundle()?.actorUid,
|
|
137
|
+
}, now);
|
|
138
|
+
log(dir, `presence online companies=${companies.length}`);
|
|
139
|
+
},
|
|
140
|
+
onError: (err) => {
|
|
141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
142
|
+
patchDaemonState(dir, { lastErrorCode: msg.slice(0, 120) }, now);
|
|
143
|
+
log(dir, `presence error: ${msg.slice(0, 200)}`);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
presence = new PresenceClient(presenceOpts);
|
|
147
|
+
try {
|
|
148
|
+
await presence.start();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
152
|
+
log(dir, `presence start failed: ${msg.slice(0, 200)}`);
|
|
153
|
+
// Continue draining spool even if MQTT is down.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let stopped = false;
|
|
157
|
+
let flushInFlight = null;
|
|
158
|
+
let debounceHandle = null;
|
|
159
|
+
let flushIntervalHandle = null;
|
|
160
|
+
let boardIntervalHandle = null;
|
|
161
|
+
let transcriptIntervalHandle = null;
|
|
162
|
+
let watcher = null;
|
|
163
|
+
/** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
|
|
164
|
+
const lastHookEventAt = new Map();
|
|
165
|
+
let transcriptWatcher = null;
|
|
166
|
+
if (deps.enableTranscriptWatch !== false) {
|
|
167
|
+
const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
|
|
168
|
+
const getToken = deps.getToken ?? defaultGetToken;
|
|
169
|
+
transcriptWatcher = new TranscriptWatcher({
|
|
170
|
+
home,
|
|
171
|
+
env,
|
|
172
|
+
workContextRoot: ctxRoot,
|
|
173
|
+
workMeshRoot: meshRoot,
|
|
174
|
+
hqRoot: hqRoot || undefined,
|
|
175
|
+
fs: deps.transcriptFs,
|
|
176
|
+
now: () => now().getTime(),
|
|
177
|
+
lastHookEventAt,
|
|
178
|
+
validateMembership: async (slug) => {
|
|
179
|
+
try {
|
|
180
|
+
const token = await getToken();
|
|
181
|
+
const membership = await resolveActiveMembershipCompany(token, slug);
|
|
182
|
+
if (!membership)
|
|
183
|
+
return false;
|
|
184
|
+
return { uid: membership.companyUid };
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
deliver: async (op) => {
|
|
191
|
+
try {
|
|
192
|
+
const token = await getToken();
|
|
193
|
+
return createWorkSessionDeliverer({ token })(op);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
retryable: true,
|
|
199
|
+
code: err instanceof Error ? err.message : "DELIVER_FAILED",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const doFlushCycle = async (reason, opts = {}) => {
|
|
206
|
+
// Refuse new cycles after stop, except the forced shutdown flush.
|
|
207
|
+
if (stopped && !opts.force)
|
|
208
|
+
return;
|
|
209
|
+
if (flushInFlight) {
|
|
210
|
+
await flushInFlight;
|
|
211
|
+
if (stopped && !opts.force)
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
flushInFlight = (async () => {
|
|
215
|
+
try {
|
|
216
|
+
// Record hook-emitted session ids from spool/held before claim/rename.
|
|
217
|
+
const at = now().getTime();
|
|
218
|
+
noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at);
|
|
219
|
+
noteHookSessionsFromSpoolFile(workMeshHeldPath(meshRoot), lastHookEventAt, at);
|
|
220
|
+
const summary = await flushFn();
|
|
221
|
+
try {
|
|
222
|
+
await replayFn();
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
226
|
+
log(dir, `outbox replay error: ${msg.slice(0, 200)}`);
|
|
227
|
+
}
|
|
228
|
+
patchDaemonState(dir, {
|
|
229
|
+
lastFlushAt: now().toISOString(),
|
|
230
|
+
lastFlushResult: { ...summary, ok: true },
|
|
231
|
+
}, now);
|
|
232
|
+
if (summary.claimed > 0 || summary.posted > 0 || summary.held > 0) {
|
|
233
|
+
log(dir, `flush(${reason}): claimed=${summary.claimed} posted=${summary.posted} held=${summary.held}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
238
|
+
patchDaemonState(dir, {
|
|
239
|
+
lastFlushAt: now().toISOString(),
|
|
240
|
+
lastFlushResult: {
|
|
241
|
+
ok: false,
|
|
242
|
+
claimed: 0,
|
|
243
|
+
posted: 0,
|
|
244
|
+
held: 0,
|
|
245
|
+
dropped: 0,
|
|
246
|
+
deadLettered: 0,
|
|
247
|
+
restored: 0,
|
|
248
|
+
batches: 0,
|
|
249
|
+
},
|
|
250
|
+
lastErrorCode: msg.slice(0, 120),
|
|
251
|
+
}, now);
|
|
252
|
+
log(dir, `flush(${reason}) error: ${msg.slice(0, 200)}`);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
flushInFlight = null;
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
await flushInFlight;
|
|
259
|
+
};
|
|
260
|
+
const scheduleDebouncedFlush = () => {
|
|
261
|
+
if (stopped)
|
|
262
|
+
return;
|
|
263
|
+
if (debounceHandle !== null) {
|
|
264
|
+
timers.clearTimeout(debounceHandle);
|
|
265
|
+
}
|
|
266
|
+
debounceHandle = timers.setTimeout(() => {
|
|
267
|
+
debounceHandle = null;
|
|
268
|
+
void doFlushCycle("watch");
|
|
269
|
+
}, SPOOL_DEBOUNCE_MS);
|
|
270
|
+
};
|
|
271
|
+
// Watch spool directory (and file) for changes.
|
|
272
|
+
const spoolPath = workMeshSpoolPath(meshRoot);
|
|
273
|
+
try {
|
|
274
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true, mode: 0o700 });
|
|
275
|
+
if (!fs.existsSync(spoolPath)) {
|
|
276
|
+
fs.writeFileSync(spoolPath, "", { mode: 0o600 });
|
|
277
|
+
}
|
|
278
|
+
watcher = fs.watch(path.dirname(spoolPath), { persistent: true }, (_event, filename) => {
|
|
279
|
+
if (stopped)
|
|
280
|
+
return;
|
|
281
|
+
if (!filename ||
|
|
282
|
+
filename === "spool.jsonl" ||
|
|
283
|
+
filename === "held.jsonl" ||
|
|
284
|
+
String(filename).startsWith("spool.") ||
|
|
285
|
+
String(filename).startsWith("held.")) {
|
|
286
|
+
scheduleDebouncedFlush();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
292
|
+
log(dir, `fs.watch failed (interval flush still active): ${msg.slice(0, 120)}`);
|
|
293
|
+
}
|
|
294
|
+
flushIntervalHandle = timers.setTimeout(function tick() {
|
|
295
|
+
if (stopped)
|
|
296
|
+
return;
|
|
297
|
+
void doFlushCycle("interval").finally(() => {
|
|
298
|
+
if (stopped)
|
|
299
|
+
return;
|
|
300
|
+
flushIntervalHandle = timers.setTimeout(tick, FLUSH_INTERVAL_MS);
|
|
301
|
+
});
|
|
302
|
+
}, FLUSH_INTERVAL_MS);
|
|
303
|
+
boardIntervalHandle = timers.setTimeout(function boardTick() {
|
|
304
|
+
if (stopped)
|
|
305
|
+
return;
|
|
306
|
+
void refreshFn()
|
|
307
|
+
.catch(() => undefined)
|
|
308
|
+
.finally(() => {
|
|
309
|
+
if (stopped)
|
|
310
|
+
return;
|
|
311
|
+
boardIntervalHandle = timers.setTimeout(boardTick, BOARD_REFRESH_INTERVAL_MS);
|
|
312
|
+
});
|
|
313
|
+
}, BOARD_REFRESH_INTERVAL_MS);
|
|
314
|
+
const runTranscriptTick = async () => {
|
|
315
|
+
if (!transcriptWatcher || stopped)
|
|
316
|
+
return;
|
|
317
|
+
try {
|
|
318
|
+
const summary = await transcriptWatcher.tick();
|
|
319
|
+
if (summary.started > 0 ||
|
|
320
|
+
summary.turns > 0 ||
|
|
321
|
+
summary.ended > 0) {
|
|
322
|
+
log(dir, `transcript-watch: started=${summary.started} turns=${summary.turns} ended=${summary.ended} skippedHook=${summary.skippedHookCovered}`);
|
|
323
|
+
scheduleDebouncedFlush();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
328
|
+
log(dir, `transcript-watch error: ${msg.slice(0, 200)}`);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
if (transcriptWatcher) {
|
|
332
|
+
transcriptIntervalHandle = timers.setTimeout(function transcriptTick() {
|
|
333
|
+
if (stopped)
|
|
334
|
+
return;
|
|
335
|
+
void runTranscriptTick().finally(() => {
|
|
336
|
+
if (stopped)
|
|
337
|
+
return;
|
|
338
|
+
transcriptIntervalHandle = timers.setTimeout(transcriptTick, TRANSCRIPT_WATCH_INTERVAL_MS);
|
|
339
|
+
});
|
|
340
|
+
}, TRANSCRIPT_WATCH_INTERVAL_MS);
|
|
341
|
+
}
|
|
342
|
+
// Initial flush + board pass + transcript scan.
|
|
343
|
+
void doFlushCycle("start");
|
|
344
|
+
void refreshFn().catch(() => undefined);
|
|
345
|
+
void runTranscriptTick();
|
|
346
|
+
const stop = async () => {
|
|
347
|
+
if (stopped)
|
|
348
|
+
return;
|
|
349
|
+
stopped = true;
|
|
350
|
+
log(dir, "daemon stopping");
|
|
351
|
+
if (debounceHandle !== null)
|
|
352
|
+
timers.clearTimeout(debounceHandle);
|
|
353
|
+
if (flushIntervalHandle !== null)
|
|
354
|
+
timers.clearTimeout(flushIntervalHandle);
|
|
355
|
+
if (boardIntervalHandle !== null)
|
|
356
|
+
timers.clearTimeout(boardIntervalHandle);
|
|
357
|
+
if (transcriptIntervalHandle !== null) {
|
|
358
|
+
timers.clearTimeout(transcriptIntervalHandle);
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
watcher?.close();
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
/* ignore */
|
|
365
|
+
}
|
|
366
|
+
// Final flush, then clean MQTT DISCONNECT so the broker emits the
|
|
367
|
+
// lifecycle event; offline is published server-side only.
|
|
368
|
+
await doFlushCycle("shutdown", { force: true });
|
|
369
|
+
if (presence) {
|
|
370
|
+
try {
|
|
371
|
+
await presence.stop();
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
375
|
+
log(dir, `presence stop error: ${msg.slice(0, 120)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
|
|
379
|
+
releasePidLock(dir, lockDeps);
|
|
380
|
+
log(dir, "daemon stopped");
|
|
381
|
+
};
|
|
382
|
+
const onSignal = () => {
|
|
383
|
+
void stop().finally(() => exit(0));
|
|
384
|
+
};
|
|
385
|
+
processEvents.once("SIGTERM", onSignal);
|
|
386
|
+
processEvents.once("SIGINT", onSignal);
|
|
387
|
+
if (deps.runForMs !== undefined) {
|
|
388
|
+
timers.setTimeout(() => {
|
|
389
|
+
void stop();
|
|
390
|
+
}, deps.runForMs);
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
stop,
|
|
394
|
+
dir,
|
|
395
|
+
presence,
|
|
396
|
+
lastHookEventAt,
|
|
397
|
+
transcriptWatcher,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon state.json under ~/.hq/work-mesh/daemon/ (0600, metadata-only).
|
|
3
|
+
*/
|
|
4
|
+
import type { MqttConnectionState } from "./presence.js";
|
|
5
|
+
import type { FlushSummary } from "../flush.js";
|
|
6
|
+
export interface DaemonStateFile {
|
|
7
|
+
v: 1;
|
|
8
|
+
pid: number;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
mqttState: MqttConnectionState;
|
|
11
|
+
companiesOnline: string[];
|
|
12
|
+
actorUid?: string;
|
|
13
|
+
lastFlushAt?: string;
|
|
14
|
+
lastFlushResult?: FlushSummary & {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
};
|
|
17
|
+
lastErrorCode?: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
21
|
+
export declare function readDaemonState(dir: string): DaemonStateFile | null;
|
|
22
|
+
export declare function writeDaemonState(dir: string, state: DaemonStateFile): void;
|
|
23
|
+
export declare function patchDaemonState(dir: string, patch: Partial<DaemonStateFile>, now?: () => Date): DaemonStateFile;
|
|
24
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon state.json under ~/.hq/work-mesh/daemon/ (0600, metadata-only).
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { daemonStatePath } from "./paths.js";
|
|
7
|
+
export function defaultDaemonState(pid, now = () => new Date()) {
|
|
8
|
+
const at = now().toISOString();
|
|
9
|
+
return {
|
|
10
|
+
v: 1,
|
|
11
|
+
pid,
|
|
12
|
+
startedAt: at,
|
|
13
|
+
mqttState: "idle",
|
|
14
|
+
companiesOnline: [],
|
|
15
|
+
updatedAt: at,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function readDaemonState(dir) {
|
|
19
|
+
const p = daemonStatePath(dir);
|
|
20
|
+
if (!fs.existsSync(p))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
24
|
+
if (raw.v !== 1 || typeof raw.pid !== "number")
|
|
25
|
+
return null;
|
|
26
|
+
return raw;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function writeDaemonState(dir, state) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
const dest = daemonStatePath(dir);
|
|
35
|
+
const tmp = path.join(dir, `.state.tmp.${process.pid}.${Date.now()}`);
|
|
36
|
+
const body = `${JSON.stringify(state, null, 2)}\n`;
|
|
37
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
38
|
+
fs.renameSync(tmp, dest);
|
|
39
|
+
try {
|
|
40
|
+
fs.chmodSync(dest, 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* best-effort */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function patchDaemonState(dir, patch, now = () => new Date()) {
|
|
47
|
+
const current = readDaemonState(dir) ?? defaultDaemonState(process.pid, now);
|
|
48
|
+
const next = {
|
|
49
|
+
...current,
|
|
50
|
+
...patch,
|
|
51
|
+
v: 1,
|
|
52
|
+
updatedAt: now().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
writeDaemonState(dir, next);
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript-watch fallback for hookless hosts (US-018 / AMEND-001).
|
|
3
|
+
*
|
|
4
|
+
* Enumerates ~/.claude/projects/ (recursive *.jsonl) and ~/.codex/sessions/
|
|
5
|
+
* every 15s using only readdir + stat (mtime). Never opens or reads transcript bytes.
|
|
6
|
+
*
|
|
7
|
+
* Session identity (deterministic):
|
|
8
|
+
* - Claude: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
|
|
9
|
+
* sessionId = basename without .jsonl. Paths under /subagents/ are skipped.
|
|
10
|
+
* cwd is decoded from the project dir name ("/" replaced by "-" encoding).
|
|
11
|
+
* - Codex: ~/.codex/sessions/.../rollout-*-<sessionId>.jsonl
|
|
12
|
+
* sessionId = trailing UUID in the filename, else basename without .jsonl.
|
|
13
|
+
* cwd is not recoverable from the path (mtime-only); left unset.
|
|
14
|
+
*
|
|
15
|
+
* Network: harness claude-desktop | codex, source "transcript" on every event.
|
|
16
|
+
* Local state keeps transcriptMarker for channel-card / Atlas before network sync;
|
|
17
|
+
* localHarness is no longer written (harness carries claude-desktop on the wire).
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import type { MembershipValidationResult } from "../../../work-context/config.js";
|
|
21
|
+
import { reconcileObservation, type ReconcileDeps } from "../../../work-context/reconcile.js";
|
|
22
|
+
import { readSessionState, writeSessionState, type SessionStateFile } from "../../../work-context/state.js";
|
|
23
|
+
import { enqueueSessionEvent } from "../enqueue.js";
|
|
24
|
+
import type { SessionEventHarness } from "../format-spool-line.js";
|
|
25
|
+
export declare const TRANSCRIPT_WATCH_INTERVAL_MS = 15000;
|
|
26
|
+
export declare const TRANSCRIPT_TURN_QUIET_MS = 5000;
|
|
27
|
+
export declare const TRANSCRIPT_SESSION_END_MS: number;
|
|
28
|
+
export declare const TRANSCRIPT_HOOK_COVER_MS = 60000;
|
|
29
|
+
/** Ignore cold transcripts whose mtime is older than this on first sight. */
|
|
30
|
+
export declare const TRANSCRIPT_FRESH_MS: number;
|
|
31
|
+
export declare const TRANSCRIPT_MARKER: "from transcript";
|
|
32
|
+
export declare const TRANSCRIPT_ADAPTER_VERSION: string;
|
|
33
|
+
/** Network `source` stamped on every synthesized event (AMEND-001). */
|
|
34
|
+
export declare const TRANSCRIPT_EVENT_SOURCE: "transcript";
|
|
35
|
+
/** Fs surface used by the watcher — intentionally no open/readFile. */
|
|
36
|
+
export interface TranscriptFs {
|
|
37
|
+
readdirSync: (dir: string, opts?: {
|
|
38
|
+
withFileTypes: true;
|
|
39
|
+
}) => fs.Dirent[] | string[];
|
|
40
|
+
statSync: (p: string) => {
|
|
41
|
+
mtimeMs: number;
|
|
42
|
+
isFile(): boolean;
|
|
43
|
+
isDirectory(): boolean;
|
|
44
|
+
};
|
|
45
|
+
existsSync: (p: string) => boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare const defaultTranscriptFs: TranscriptFs;
|
|
48
|
+
export type TranscriptKind = "claude" | "codex";
|
|
49
|
+
export interface DiscoveredTranscript {
|
|
50
|
+
path: string;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
kind: TranscriptKind;
|
|
53
|
+
/** Best-effort cwd (Claude project-dir decode only). */
|
|
54
|
+
cwd?: string;
|
|
55
|
+
mtimeMs: number;
|
|
56
|
+
/** Network harness (schema enum): claude-desktop | codex. */
|
|
57
|
+
harness: SessionEventHarness;
|
|
58
|
+
}
|
|
59
|
+
export interface TranscriptWatchDeps {
|
|
60
|
+
home?: string;
|
|
61
|
+
env?: NodeJS.ProcessEnv;
|
|
62
|
+
workContextRoot: string;
|
|
63
|
+
workMeshRoot?: string;
|
|
64
|
+
hqRoot?: string;
|
|
65
|
+
fs?: TranscriptFs;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
/**
|
|
68
|
+
* Last hook-emitted activity per sessionId (epoch ms). Daemon updates this
|
|
69
|
+
* when spool flush sees hook events. Sessions active within
|
|
70
|
+
* TRANSCRIPT_HOOK_COVER_MS are not synthesized.
|
|
71
|
+
*/
|
|
72
|
+
lastHookEventAt?: Map<string, number>;
|
|
73
|
+
/** Injectable enqueue (tests). */
|
|
74
|
+
enqueue?: typeof enqueueSessionEvent;
|
|
75
|
+
/** Injectable state read/write (tests). */
|
|
76
|
+
readState?: typeof readSessionState;
|
|
77
|
+
writeState?: typeof writeSessionState;
|
|
78
|
+
/**
|
|
79
|
+
* Membership-validated slug→uid for reconcile (flush needs companyUid).
|
|
80
|
+
* Injectable for tests; daemon wires resolveActiveMembershipCompany.
|
|
81
|
+
*/
|
|
82
|
+
validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
|
|
83
|
+
/** Optional outbox deliverer (daemon); offline when omitted. */
|
|
84
|
+
deliver?: ReconcileDeps["deliver"];
|
|
85
|
+
/** Injectable reconcile (tests). */
|
|
86
|
+
reconcile?: typeof reconcileObservation;
|
|
87
|
+
claudeProjectsDir?: string;
|
|
88
|
+
codexSessionsDir?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface TranscriptWatchTickResult {
|
|
91
|
+
discovered: number;
|
|
92
|
+
started: number;
|
|
93
|
+
turns: number;
|
|
94
|
+
ended: number;
|
|
95
|
+
skippedHookCovered: number;
|
|
96
|
+
skippedStale: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Claude Code encodes an absolute cwd as a directory name by replacing every
|
|
100
|
+
* "/" with "-". Decoding is best-effort (hyphenated path segments are ambiguous).
|
|
101
|
+
*/
|
|
102
|
+
export declare function decodeClaudeProjectDirName(encoded: string): string | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Derive session id from a Claude transcript path.
|
|
105
|
+
* Rule: basename without .jsonl; skip anything under /subagents/.
|
|
106
|
+
*/
|
|
107
|
+
export declare function sessionIdFromClaudeTranscriptPath(filePath: string): string | null;
|
|
108
|
+
/**
|
|
109
|
+
* Derive session id from a Codex transcript path.
|
|
110
|
+
* Rule: trailing UUID in basename, else basename without extension.
|
|
111
|
+
*/
|
|
112
|
+
export declare function sessionIdFromCodexTranscriptPath(filePath: string): string | null;
|
|
113
|
+
/**
|
|
114
|
+
* Enumerate transcript files with mtime only (no content read).
|
|
115
|
+
*/
|
|
116
|
+
export declare function discoverTranscripts(opts: {
|
|
117
|
+
home?: string;
|
|
118
|
+
fs?: TranscriptFs;
|
|
119
|
+
claudeProjectsDir?: string;
|
|
120
|
+
codexSessionsDir?: string;
|
|
121
|
+
}): DiscoveredTranscript[];
|
|
122
|
+
/**
|
|
123
|
+
* Whether hooks already cover this session: durable hook-written state, or a
|
|
124
|
+
* hook event observed within the last TRANSCRIPT_HOOK_COVER_MS.
|
|
125
|
+
*/
|
|
126
|
+
export declare function isTranscriptHookCovered(sessionId: string, opts: {
|
|
127
|
+
workContextRoot: string;
|
|
128
|
+
nowMs: number;
|
|
129
|
+
lastHookEventAt?: Map<string, number>;
|
|
130
|
+
readState?: typeof readSessionState;
|
|
131
|
+
coverMs?: number;
|
|
132
|
+
}): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Resolve local registration for a transcript session. Never asks, never
|
|
135
|
+
* creates a project. Uses deterministic cwd/remote mapping only.
|
|
136
|
+
*/
|
|
137
|
+
export declare function resolveTranscriptRegistration(input: {
|
|
138
|
+
sessionId: string;
|
|
139
|
+
cwd?: string;
|
|
140
|
+
hqRoot?: string;
|
|
141
|
+
workContextRoot: string;
|
|
142
|
+
now?: () => Date;
|
|
143
|
+
}): SessionStateFile;
|
|
144
|
+
/**
|
|
145
|
+
* Scan a spool/held jsonl for hook-emitted session ids (not transcript-watch).
|
|
146
|
+
* Reads spool metadata only — never a transcript file.
|
|
147
|
+
*/
|
|
148
|
+
export declare function noteHookSessionsFromSpoolFile(spoolPath: string, lastHookEventAt: Map<string, number>, atMs: number, tfs?: Pick<TranscriptFs, "existsSync"> & {
|
|
149
|
+
readFileSync?: (p: string, enc: "utf8") => string;
|
|
150
|
+
}): string[];
|
|
151
|
+
export declare class TranscriptWatcher {
|
|
152
|
+
private readonly tracked;
|
|
153
|
+
private readonly deps;
|
|
154
|
+
private readonly tfs;
|
|
155
|
+
private readonly enqueueFn;
|
|
156
|
+
private readonly readState;
|
|
157
|
+
private readonly writeState;
|
|
158
|
+
private readonly reconcileFn;
|
|
159
|
+
constructor(deps: TranscriptWatchDeps);
|
|
160
|
+
/** Record that a hook path emitted an event for sessionId (daemon flush). */
|
|
161
|
+
noteHookEvent(sessionId: string, atMs?: number): void;
|
|
162
|
+
tick(): Promise<TranscriptWatchTickResult>;
|
|
163
|
+
/**
|
|
164
|
+
* Register via reconcileObservation (membership-validated slug→uid), then
|
|
165
|
+
* enqueue session_start. Never overwrites a hook-written state.
|
|
166
|
+
*/
|
|
167
|
+
private emitStart;
|
|
168
|
+
private emitKind;
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=transcript-watch.d.ts.map
|