@indigoai-us/hq-cli 5.108.0 → 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 +79 -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/cloud.d.ts +6 -0
- package/dist/commands/cloud.js +26 -3
- 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,520 @@
|
|
|
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 * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
23
|
+
import { companySlugFromCwd, projectIdFromCwd, resolveDeterministicCompany, } from "../../../work-context/company.js";
|
|
24
|
+
import { getDefaultCompany } from "../../../work-context/config.js";
|
|
25
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../../work-context/contract.js";
|
|
26
|
+
import { reconcileObservation, } from "../../../work-context/reconcile.js";
|
|
27
|
+
import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
28
|
+
import { isHookWrittenSessionState, readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
29
|
+
import { enqueueSessionEvent } from "../enqueue.js";
|
|
30
|
+
import { isValidSessionId } from "../session-identity.js";
|
|
31
|
+
export const TRANSCRIPT_WATCH_INTERVAL_MS = 15_000;
|
|
32
|
+
export const TRANSCRIPT_TURN_QUIET_MS = 5_000;
|
|
33
|
+
export const TRANSCRIPT_SESSION_END_MS = 10 * 60_000;
|
|
34
|
+
export const TRANSCRIPT_HOOK_COVER_MS = 60_000;
|
|
35
|
+
/** Ignore cold transcripts whose mtime is older than this on first sight. */
|
|
36
|
+
export const TRANSCRIPT_FRESH_MS = TRANSCRIPT_SESSION_END_MS;
|
|
37
|
+
export const TRANSCRIPT_MARKER = "from transcript";
|
|
38
|
+
export const TRANSCRIPT_ADAPTER_VERSION = CLI_VERSION;
|
|
39
|
+
/** Network `source` stamped on every synthesized event (AMEND-001). */
|
|
40
|
+
export const TRANSCRIPT_EVENT_SOURCE = "transcript";
|
|
41
|
+
export const defaultTranscriptFs = {
|
|
42
|
+
readdirSync: (dir, opts) => opts?.withFileTypes
|
|
43
|
+
? fs.readdirSync(dir, { withFileTypes: true })
|
|
44
|
+
: fs.readdirSync(dir),
|
|
45
|
+
statSync: (p) => fs.statSync(p),
|
|
46
|
+
existsSync: (p) => fs.existsSync(p),
|
|
47
|
+
};
|
|
48
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
49
|
+
/**
|
|
50
|
+
* Claude Code encodes an absolute cwd as a directory name by replacing every
|
|
51
|
+
* "/" with "-". Decoding is best-effort (hyphenated path segments are ambiguous).
|
|
52
|
+
*/
|
|
53
|
+
export function decodeClaudeProjectDirName(encoded) {
|
|
54
|
+
const name = encoded.trim();
|
|
55
|
+
if (!name.startsWith("-") || name.length < 2)
|
|
56
|
+
return undefined;
|
|
57
|
+
return name.replace(/-/g, "/");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Derive session id from a Claude transcript path.
|
|
61
|
+
* Rule: basename without .jsonl; skip anything under /subagents/.
|
|
62
|
+
*/
|
|
63
|
+
export function sessionIdFromClaudeTranscriptPath(filePath) {
|
|
64
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
65
|
+
if (normalized.includes("/subagents/"))
|
|
66
|
+
return null;
|
|
67
|
+
const base = path.basename(filePath);
|
|
68
|
+
if (!base.endsWith(".jsonl"))
|
|
69
|
+
return null;
|
|
70
|
+
const id = base.slice(0, -".jsonl".length);
|
|
71
|
+
if (!isValidSessionId(id))
|
|
72
|
+
return null;
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Derive session id from a Codex transcript path.
|
|
77
|
+
* Rule: trailing UUID in basename, else basename without extension.
|
|
78
|
+
*/
|
|
79
|
+
export function sessionIdFromCodexTranscriptPath(filePath) {
|
|
80
|
+
const base = path.basename(filePath);
|
|
81
|
+
const stem = base.endsWith(".jsonl")
|
|
82
|
+
? base.slice(0, -".jsonl".length)
|
|
83
|
+
: base.includes(".")
|
|
84
|
+
? base.slice(0, base.lastIndexOf("."))
|
|
85
|
+
: base;
|
|
86
|
+
const matches = stem.match(new RegExp(UUID_RE.source, "gi"));
|
|
87
|
+
const id = matches?.[matches.length - 1] ?? stem;
|
|
88
|
+
if (!isValidSessionId(id))
|
|
89
|
+
return null;
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
function listJsonlFiles(root, tfs) {
|
|
93
|
+
if (!tfs.existsSync(root))
|
|
94
|
+
return [];
|
|
95
|
+
const out = [];
|
|
96
|
+
const stack = [root];
|
|
97
|
+
while (stack.length > 0) {
|
|
98
|
+
const dir = stack.pop();
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = tfs.readdirSync(dir, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
108
|
+
const full = path.join(dir, name);
|
|
109
|
+
let isDir;
|
|
110
|
+
let isFile;
|
|
111
|
+
if (typeof entry !== "string" && typeof entry.isDirectory === "function") {
|
|
112
|
+
isDir = entry.isDirectory();
|
|
113
|
+
isFile = entry.isFile();
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
try {
|
|
117
|
+
const st = tfs.statSync(full);
|
|
118
|
+
isDir = st.isDirectory();
|
|
119
|
+
isFile = st.isFile();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (isDir) {
|
|
126
|
+
stack.push(full);
|
|
127
|
+
}
|
|
128
|
+
else if (isFile && name.endsWith(".jsonl")) {
|
|
129
|
+
out.push(full);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function listCodexSessionFiles(root, tfs) {
|
|
136
|
+
if (!tfs.existsSync(root))
|
|
137
|
+
return [];
|
|
138
|
+
const out = [];
|
|
139
|
+
const stack = [root];
|
|
140
|
+
while (stack.length > 0) {
|
|
141
|
+
const dir = stack.pop();
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = tfs.readdirSync(dir, { withFileTypes: true });
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
151
|
+
const full = path.join(dir, name);
|
|
152
|
+
let isDir;
|
|
153
|
+
let isFile;
|
|
154
|
+
if (typeof entry !== "string" && typeof entry.isDirectory === "function") {
|
|
155
|
+
isDir = entry.isDirectory();
|
|
156
|
+
isFile = entry.isFile();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
try {
|
|
160
|
+
const st = tfs.statSync(full);
|
|
161
|
+
isDir = st.isDirectory();
|
|
162
|
+
isFile = st.isFile();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (isDir) {
|
|
169
|
+
stack.push(full);
|
|
170
|
+
}
|
|
171
|
+
else if (isFile) {
|
|
172
|
+
// Codex stores rollout-*.jsonl (and occasionally other session files).
|
|
173
|
+
out.push(full);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Enumerate transcript files with mtime only (no content read).
|
|
181
|
+
*/
|
|
182
|
+
export function discoverTranscripts(opts) {
|
|
183
|
+
const home = opts.home ?? os.homedir();
|
|
184
|
+
const tfs = opts.fs ?? defaultTranscriptFs;
|
|
185
|
+
const claudeRoot = opts.claudeProjectsDir ?? path.join(home, ".claude", "projects");
|
|
186
|
+
const codexRoot = opts.codexSessionsDir ?? path.join(home, ".codex", "sessions");
|
|
187
|
+
const found = [];
|
|
188
|
+
for (const filePath of listJsonlFiles(claudeRoot, tfs)) {
|
|
189
|
+
const sessionId = sessionIdFromClaudeTranscriptPath(filePath);
|
|
190
|
+
if (!sessionId)
|
|
191
|
+
continue;
|
|
192
|
+
let mtimeMs;
|
|
193
|
+
try {
|
|
194
|
+
const st = tfs.statSync(filePath);
|
|
195
|
+
if (!st.isFile())
|
|
196
|
+
continue;
|
|
197
|
+
mtimeMs = st.mtimeMs;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const projectDir = path.basename(path.dirname(filePath));
|
|
203
|
+
const cwd = decodeClaudeProjectDirName(projectDir);
|
|
204
|
+
found.push({
|
|
205
|
+
path: filePath,
|
|
206
|
+
sessionId,
|
|
207
|
+
kind: "claude",
|
|
208
|
+
cwd,
|
|
209
|
+
mtimeMs,
|
|
210
|
+
harness: "claude-desktop",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
for (const filePath of listCodexSessionFiles(codexRoot, tfs)) {
|
|
214
|
+
if (!filePath.endsWith(".jsonl") && !path.basename(filePath).startsWith("rollout-")) {
|
|
215
|
+
// Prefer jsonl; still accept rollout-* without extension edge cases via id parse.
|
|
216
|
+
if (!path.basename(filePath).startsWith("rollout-"))
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const sessionId = sessionIdFromCodexTranscriptPath(filePath);
|
|
220
|
+
if (!sessionId)
|
|
221
|
+
continue;
|
|
222
|
+
let mtimeMs;
|
|
223
|
+
try {
|
|
224
|
+
const st = tfs.statSync(filePath);
|
|
225
|
+
if (!st.isFile())
|
|
226
|
+
continue;
|
|
227
|
+
mtimeMs = st.mtimeMs;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
found.push({
|
|
233
|
+
path: filePath,
|
|
234
|
+
sessionId,
|
|
235
|
+
kind: "codex",
|
|
236
|
+
mtimeMs,
|
|
237
|
+
harness: "codex",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether hooks already cover this session: durable hook-written state, or a
|
|
244
|
+
* hook event observed within the last TRANSCRIPT_HOOK_COVER_MS.
|
|
245
|
+
*/
|
|
246
|
+
export function isTranscriptHookCovered(sessionId, opts) {
|
|
247
|
+
const read = opts.readState ?? readSessionState;
|
|
248
|
+
const state = read(sessionId, opts.workContextRoot);
|
|
249
|
+
if (isHookWrittenSessionState(state))
|
|
250
|
+
return true;
|
|
251
|
+
const last = opts.lastHookEventAt?.get(sessionId);
|
|
252
|
+
if (last === undefined)
|
|
253
|
+
return false;
|
|
254
|
+
return opts.nowMs - last < (opts.coverMs ?? TRANSCRIPT_HOOK_COVER_MS);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve local registration for a transcript session. Never asks, never
|
|
258
|
+
* creates a project. Uses deterministic cwd/remote mapping only.
|
|
259
|
+
*/
|
|
260
|
+
export function resolveTranscriptRegistration(input) {
|
|
261
|
+
const now = input.now ?? (() => new Date());
|
|
262
|
+
const updatedAt = now().toISOString();
|
|
263
|
+
const remoteOwnerSlug = input.cwd
|
|
264
|
+
? deriveRemoteOwnerSlug({ cwd: input.cwd, hqRoot: input.hqRoot })
|
|
265
|
+
: null;
|
|
266
|
+
const deterministic = resolveDeterministicCompany({
|
|
267
|
+
cwd: input.cwd,
|
|
268
|
+
hqRoot: input.hqRoot,
|
|
269
|
+
remoteOwnerSlug,
|
|
270
|
+
});
|
|
271
|
+
const projectId = projectIdFromCwd(input.cwd, input.hqRoot);
|
|
272
|
+
// Prefer company slug from deterministic evidence; optionally adopt uid when
|
|
273
|
+
// the enabled device default names the same slug (no conflict ask).
|
|
274
|
+
const companySlug = deterministic?.slug ?? companySlugFromCwd(input.cwd, input.hqRoot);
|
|
275
|
+
let companyUid;
|
|
276
|
+
const device = getDefaultCompany({ root: input.workContextRoot });
|
|
277
|
+
if (companySlug &&
|
|
278
|
+
device?.enabled &&
|
|
279
|
+
device.slug &&
|
|
280
|
+
device.slug.toLowerCase() === companySlug.toLowerCase()) {
|
|
281
|
+
companyUid = device.uid;
|
|
282
|
+
}
|
|
283
|
+
let contextStatus;
|
|
284
|
+
if (!companySlug) {
|
|
285
|
+
contextStatus = "needs_company";
|
|
286
|
+
}
|
|
287
|
+
else if (projectId) {
|
|
288
|
+
contextStatus = "needs_task";
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
contextStatus = "unresolved";
|
|
292
|
+
}
|
|
293
|
+
const state = {
|
|
294
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
295
|
+
sessionId: input.sessionId,
|
|
296
|
+
contextStatus,
|
|
297
|
+
updatedAt,
|
|
298
|
+
source: "transcript",
|
|
299
|
+
// Keep local marker for channel-card / Atlas before SESSION_STATE sync.
|
|
300
|
+
transcriptMarker: TRANSCRIPT_MARKER,
|
|
301
|
+
};
|
|
302
|
+
if (companySlug)
|
|
303
|
+
state.companySlug = companySlug;
|
|
304
|
+
if (companyUid)
|
|
305
|
+
state.companyUid = companyUid;
|
|
306
|
+
if (projectId && companySlug)
|
|
307
|
+
state.projectId = projectId;
|
|
308
|
+
return state;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Scan a spool/held jsonl for hook-emitted session ids (not transcript-watch).
|
|
312
|
+
* Reads spool metadata only — never a transcript file.
|
|
313
|
+
*/
|
|
314
|
+
export function noteHookSessionsFromSpoolFile(spoolPath, lastHookEventAt, atMs, tfs = defaultTranscriptFs) {
|
|
315
|
+
if (!tfs.existsSync(spoolPath))
|
|
316
|
+
return [];
|
|
317
|
+
const readFile = tfs.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
|
318
|
+
let text;
|
|
319
|
+
try {
|
|
320
|
+
text = readFile(spoolPath, "utf8");
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const noted = [];
|
|
326
|
+
for (const line of text.split(/\r?\n/)) {
|
|
327
|
+
if (!line.trim())
|
|
328
|
+
continue;
|
|
329
|
+
try {
|
|
330
|
+
const ev = JSON.parse(line);
|
|
331
|
+
if (typeof ev.sessionId !== "string" || !ev.sessionId)
|
|
332
|
+
continue;
|
|
333
|
+
// Synthesized transcript events carry source=transcript — not hooks.
|
|
334
|
+
if (ev.source === TRANSCRIPT_EVENT_SOURCE)
|
|
335
|
+
continue;
|
|
336
|
+
lastHookEventAt.set(ev.sessionId, atMs);
|
|
337
|
+
noted.push(ev.sessionId);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* ignore malformed */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return noted;
|
|
344
|
+
}
|
|
345
|
+
export class TranscriptWatcher {
|
|
346
|
+
tracked = new Map();
|
|
347
|
+
deps;
|
|
348
|
+
tfs;
|
|
349
|
+
enqueueFn;
|
|
350
|
+
readState;
|
|
351
|
+
writeState;
|
|
352
|
+
reconcileFn;
|
|
353
|
+
constructor(deps) {
|
|
354
|
+
this.deps = deps;
|
|
355
|
+
this.tfs = deps.fs ?? defaultTranscriptFs;
|
|
356
|
+
this.enqueueFn = deps.enqueue ?? enqueueSessionEvent;
|
|
357
|
+
this.readState = deps.readState ?? readSessionState;
|
|
358
|
+
this.writeState = deps.writeState ?? writeSessionState;
|
|
359
|
+
this.reconcileFn = deps.reconcile ?? reconcileObservation;
|
|
360
|
+
}
|
|
361
|
+
/** Record that a hook path emitted an event for sessionId (daemon flush). */
|
|
362
|
+
noteHookEvent(sessionId, atMs) {
|
|
363
|
+
const map = this.deps.lastHookEventAt;
|
|
364
|
+
if (!map)
|
|
365
|
+
return;
|
|
366
|
+
map.set(sessionId, atMs ?? (this.deps.now?.() ?? Date.now()));
|
|
367
|
+
}
|
|
368
|
+
async tick() {
|
|
369
|
+
const nowMs = this.deps.now?.() ?? Date.now();
|
|
370
|
+
const home = this.deps.home ?? os.homedir();
|
|
371
|
+
const result = {
|
|
372
|
+
discovered: 0,
|
|
373
|
+
started: 0,
|
|
374
|
+
turns: 0,
|
|
375
|
+
ended: 0,
|
|
376
|
+
skippedHookCovered: 0,
|
|
377
|
+
skippedStale: 0,
|
|
378
|
+
};
|
|
379
|
+
const discovered = discoverTranscripts({
|
|
380
|
+
home,
|
|
381
|
+
fs: this.tfs,
|
|
382
|
+
claudeProjectsDir: this.deps.claudeProjectsDir,
|
|
383
|
+
codexSessionsDir: this.deps.codexSessionsDir,
|
|
384
|
+
});
|
|
385
|
+
result.discovered = discovered.length;
|
|
386
|
+
const seenPaths = new Set();
|
|
387
|
+
for (const d of discovered) {
|
|
388
|
+
seenPaths.add(d.path);
|
|
389
|
+
if (isTranscriptHookCovered(d.sessionId, {
|
|
390
|
+
workContextRoot: this.deps.workContextRoot,
|
|
391
|
+
nowMs,
|
|
392
|
+
lastHookEventAt: this.deps.lastHookEventAt,
|
|
393
|
+
readState: this.readState,
|
|
394
|
+
})) {
|
|
395
|
+
result.skippedHookCovered += 1;
|
|
396
|
+
this.tracked.delete(d.path);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
let track = this.tracked.get(d.path);
|
|
400
|
+
if (!track) {
|
|
401
|
+
// First sight: only adopt fresh or actively-written transcripts.
|
|
402
|
+
if (nowMs - d.mtimeMs > TRANSCRIPT_FRESH_MS) {
|
|
403
|
+
result.skippedStale += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
track = {
|
|
407
|
+
path: d.path,
|
|
408
|
+
sessionId: d.sessionId,
|
|
409
|
+
kind: d.kind,
|
|
410
|
+
cwd: d.cwd,
|
|
411
|
+
harness: d.harness,
|
|
412
|
+
lastMtimeMs: d.mtimeMs,
|
|
413
|
+
lastChangeAtMs: d.mtimeMs,
|
|
414
|
+
started: false,
|
|
415
|
+
ended: false,
|
|
416
|
+
pendingTurn: false,
|
|
417
|
+
seq: 0,
|
|
418
|
+
};
|
|
419
|
+
this.tracked.set(d.path, track);
|
|
420
|
+
const started = await this.emitStart(track, nowMs);
|
|
421
|
+
if (started)
|
|
422
|
+
result.started += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (track.ended)
|
|
426
|
+
continue;
|
|
427
|
+
if (d.mtimeMs > track.lastMtimeMs) {
|
|
428
|
+
track.lastMtimeMs = d.mtimeMs;
|
|
429
|
+
track.lastChangeAtMs = nowMs;
|
|
430
|
+
if (track.started) {
|
|
431
|
+
track.pendingTurn = true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (track.pendingTurn &&
|
|
435
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
|
|
436
|
+
this.emitKind(track, "turn_end", nowMs);
|
|
437
|
+
track.pendingTurn = false;
|
|
438
|
+
result.turns += 1;
|
|
439
|
+
}
|
|
440
|
+
if (track.started &&
|
|
441
|
+
!track.ended &&
|
|
442
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
|
|
443
|
+
this.emitKind(track, "session_end", nowMs);
|
|
444
|
+
track.ended = true;
|
|
445
|
+
result.ended += 1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Sessions that disappeared from disk: end if still open.
|
|
449
|
+
for (const [p, track] of [...this.tracked.entries()]) {
|
|
450
|
+
if (seenPaths.has(p))
|
|
451
|
+
continue;
|
|
452
|
+
if (track.started && !track.ended) {
|
|
453
|
+
this.emitKind(track, "session_end", nowMs);
|
|
454
|
+
track.ended = true;
|
|
455
|
+
result.ended += 1;
|
|
456
|
+
}
|
|
457
|
+
this.tracked.delete(p);
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Register via reconcileObservation (membership-validated slug→uid), then
|
|
463
|
+
* enqueue session_start. Never overwrites a hook-written state.
|
|
464
|
+
*/
|
|
465
|
+
async emitStart(track, nowMs) {
|
|
466
|
+
const prior = this.readState(track.sessionId, this.deps.workContextRoot);
|
|
467
|
+
if (isHookWrittenSessionState(prior)) {
|
|
468
|
+
track.ended = true;
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
const observation = {
|
|
472
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
473
|
+
identity: {
|
|
474
|
+
sessionId: track.sessionId,
|
|
475
|
+
harness: track.harness,
|
|
476
|
+
adapterVersion: TRANSCRIPT_ADAPTER_VERSION,
|
|
477
|
+
},
|
|
478
|
+
clientOperationId: `transcript_start_${track.sessionId}`,
|
|
479
|
+
cwd: track.cwd,
|
|
480
|
+
hqRoot: this.deps.hqRoot,
|
|
481
|
+
source: "transcript",
|
|
482
|
+
};
|
|
483
|
+
await this.reconcileFn(observation, {
|
|
484
|
+
root: this.deps.workContextRoot,
|
|
485
|
+
env: this.deps.env,
|
|
486
|
+
now: () => new Date(nowMs),
|
|
487
|
+
validateMembership: this.deps.validateMembership,
|
|
488
|
+
deliver: this.deps.deliver,
|
|
489
|
+
offline: !this.deps.deliver,
|
|
490
|
+
});
|
|
491
|
+
// Re-check: a hook may have raced us during reconcile.
|
|
492
|
+
const after = this.readState(track.sessionId, this.deps.workContextRoot);
|
|
493
|
+
if (isHookWrittenSessionState(after)) {
|
|
494
|
+
track.ended = true;
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
this.emitKind(track, "session_start", nowMs);
|
|
498
|
+
track.started = true;
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
emitKind(track, kind, nowMs) {
|
|
502
|
+
track.seq += 1;
|
|
503
|
+
const opts = {
|
|
504
|
+
kind,
|
|
505
|
+
sessionId: track.sessionId,
|
|
506
|
+
harness: track.harness,
|
|
507
|
+
adapterVersion: TRANSCRIPT_ADAPTER_VERSION,
|
|
508
|
+
source: TRANSCRIPT_EVENT_SOURCE,
|
|
509
|
+
at: new Date(nowMs).toISOString(),
|
|
510
|
+
seq: track.seq,
|
|
511
|
+
cwd: track.cwd,
|
|
512
|
+
hqRoot: this.deps.hqRoot,
|
|
513
|
+
root: this.deps.workMeshRoot,
|
|
514
|
+
env: this.deps.env,
|
|
515
|
+
now: () => new Date(nowMs),
|
|
516
|
+
};
|
|
517
|
+
this.enqueueFn(opts);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
//# sourceMappingURL=transcript-watch.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local session-event enqueue (US-008): validate + append one spool line.
|
|
3
|
+
* No network. Shared line format with work-mesh-enqueue.sh.
|
|
4
|
+
*/
|
|
5
|
+
import { type SessionEventHarness, type SessionEventKind, type SessionEventSource, type SpoolEventInput } from "./format-spool-line.js";
|
|
6
|
+
export declare class EnqueueValidationError extends Error {
|
|
7
|
+
readonly code: "ENQUEUE_VALIDATION";
|
|
8
|
+
readonly errors: string[];
|
|
9
|
+
constructor(errors: string[]);
|
|
10
|
+
}
|
|
11
|
+
export declare const CLI_KIND_TO_SCHEMA: Record<string, SessionEventKind>;
|
|
12
|
+
export interface EnqueueOptions {
|
|
13
|
+
kind: SessionEventKind;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
harness: SessionEventHarness;
|
|
16
|
+
adapterVersion: string;
|
|
17
|
+
runtimeVersion?: string;
|
|
18
|
+
source?: SessionEventSource;
|
|
19
|
+
at?: string;
|
|
20
|
+
seq: number;
|
|
21
|
+
eventId?: string;
|
|
22
|
+
taskId?: string;
|
|
23
|
+
status?: "queued" | "in_progress" | "review" | "done";
|
|
24
|
+
reason?: string;
|
|
25
|
+
summary?: string;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
hqRoot?: string;
|
|
28
|
+
companySlug?: string;
|
|
29
|
+
project?: string;
|
|
30
|
+
task?: string;
|
|
31
|
+
toolWrites?: number;
|
|
32
|
+
/** Work-mesh root override (tests). */
|
|
33
|
+
root?: string;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
now?: () => Date;
|
|
36
|
+
}
|
|
37
|
+
export interface EnqueueResult {
|
|
38
|
+
line: string;
|
|
39
|
+
spoolPath: string;
|
|
40
|
+
event: SpoolEventInput;
|
|
41
|
+
}
|
|
42
|
+
export declare function resolveEnqueueSessionId(explicit: string | undefined, env: NodeJS.ProcessEnv): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Validate and append one schema-valid spool line. Throws EnqueueValidationError
|
|
45
|
+
* (nothing written) when the event is invalid — including a missing sessionId.
|
|
46
|
+
*/
|
|
47
|
+
export declare function enqueueSessionEvent(opts: EnqueueOptions): EnqueueResult;
|
|
48
|
+
//# sourceMappingURL=enqueue.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local session-event enqueue (US-008): validate + append one spool line.
|
|
3
|
+
* No network. Shared line format with work-mesh-enqueue.sh.
|
|
4
|
+
*/
|
|
5
|
+
import { validateSessionEvent } from "./validate-session-event.js";
|
|
6
|
+
import { formatSpoolLine, } from "./format-spool-line.js";
|
|
7
|
+
import { generateUlid } from "./ulid.js";
|
|
8
|
+
import { appendSpoolLine, SpoolWriteError } from "./spool.js";
|
|
9
|
+
import { workMeshRoot } from "./paths.js";
|
|
10
|
+
import { sessionIdFromEnv } from "./session-identity.js";
|
|
11
|
+
export class EnqueueValidationError extends Error {
|
|
12
|
+
code = "ENQUEUE_VALIDATION";
|
|
13
|
+
errors;
|
|
14
|
+
constructor(errors) {
|
|
15
|
+
super(errors.join("; ") || "Session event failed schema validation");
|
|
16
|
+
this.name = "EnqueueValidationError";
|
|
17
|
+
this.errors = errors;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const CLI_KIND_TO_SCHEMA = {
|
|
21
|
+
start: "session_start",
|
|
22
|
+
"turn-start": "turn_start",
|
|
23
|
+
"turn-end": "turn_end",
|
|
24
|
+
end: "session_end",
|
|
25
|
+
"task-status": "task_status",
|
|
26
|
+
blocked: "blocked",
|
|
27
|
+
note: "note",
|
|
28
|
+
};
|
|
29
|
+
export function resolveEnqueueSessionId(explicit, env) {
|
|
30
|
+
const trimmed = explicit?.trim();
|
|
31
|
+
if (trimmed)
|
|
32
|
+
return trimmed;
|
|
33
|
+
return sessionIdFromEnv(env);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate and append one schema-valid spool line. Throws EnqueueValidationError
|
|
37
|
+
* (nothing written) when the event is invalid — including a missing sessionId.
|
|
38
|
+
*/
|
|
39
|
+
export function enqueueSessionEvent(opts) {
|
|
40
|
+
const env = opts.env ?? process.env;
|
|
41
|
+
const sessionId = resolveEnqueueSessionId(opts.sessionId, env);
|
|
42
|
+
if (!sessionId) {
|
|
43
|
+
throw new EnqueueValidationError(["sessionId: required"]);
|
|
44
|
+
}
|
|
45
|
+
const now = opts.now ?? (() => new Date());
|
|
46
|
+
const event = {
|
|
47
|
+
v: 1,
|
|
48
|
+
eventId: opts.eventId?.trim() || generateUlid(now().getTime()),
|
|
49
|
+
kind: opts.kind,
|
|
50
|
+
sessionId,
|
|
51
|
+
harness: opts.harness,
|
|
52
|
+
adapterVersion: opts.adapterVersion,
|
|
53
|
+
at: opts.at ?? now().toISOString(),
|
|
54
|
+
seq: opts.seq,
|
|
55
|
+
};
|
|
56
|
+
if (opts.runtimeVersion)
|
|
57
|
+
event.runtimeVersion = opts.runtimeVersion;
|
|
58
|
+
if (opts.source)
|
|
59
|
+
event.source = opts.source;
|
|
60
|
+
if (opts.taskId)
|
|
61
|
+
event.taskId = opts.taskId;
|
|
62
|
+
if (opts.status)
|
|
63
|
+
event.status = opts.status;
|
|
64
|
+
if (opts.reason)
|
|
65
|
+
event.reason = opts.reason;
|
|
66
|
+
if (opts.summary)
|
|
67
|
+
event.summary = opts.summary;
|
|
68
|
+
if (opts.cwd)
|
|
69
|
+
event.cwd = opts.cwd;
|
|
70
|
+
if (opts.hqRoot)
|
|
71
|
+
event.hqRoot = opts.hqRoot;
|
|
72
|
+
if (opts.companySlug)
|
|
73
|
+
event.companySlug = opts.companySlug;
|
|
74
|
+
if (opts.project)
|
|
75
|
+
event.project = opts.project;
|
|
76
|
+
if (opts.task)
|
|
77
|
+
event.task = opts.task;
|
|
78
|
+
if (opts.toolWrites !== undefined)
|
|
79
|
+
event.toolWrites = opts.toolWrites;
|
|
80
|
+
const candidate = {
|
|
81
|
+
...event,
|
|
82
|
+
v: 1,
|
|
83
|
+
};
|
|
84
|
+
const validation = validateSessionEvent(candidate);
|
|
85
|
+
if (!validation.ok) {
|
|
86
|
+
throw new EnqueueValidationError(validation.errors);
|
|
87
|
+
}
|
|
88
|
+
const line = formatSpoolLine(event);
|
|
89
|
+
const root = opts.root ?? workMeshRoot(undefined, env);
|
|
90
|
+
try {
|
|
91
|
+
const spoolPath = appendSpoolLine(line, root);
|
|
92
|
+
return { line, spoolPath, event };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err instanceof SpoolWriteError)
|
|
96
|
+
throw err;
|
|
97
|
+
throw new SpoolWriteError(err instanceof Error ? err.message : String(err));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=enqueue.js.map
|