@indigoai-us/hq-cli 5.345.62 → 5.345.64
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 +8 -0
- package/dist/command-catalog.generated.d.ts +736 -0
- package/dist/command-catalog.generated.js +907 -0
- package/dist/commands/lanes-link.d.ts +3 -0
- package/dist/commands/lanes-link.js +83 -0
- package/dist/commands/lanes-loop.js +5 -6
- package/dist/commands/lanes-questions.js +27 -3
- package/dist/commands/lanes.js +26 -4
- package/dist/lib/daemon/catalog.d.ts +5 -0
- package/dist/lib/daemon/catalog.js +3 -0
- package/dist/lib/daemon/run.d.ts +6 -0
- package/dist/lib/daemon/run.js +13 -1
- package/dist/lib/lanes/links.d.ts +39 -0
- package/dist/lib/lanes/links.js +450 -0
- package/dist/lib/lanes/loop.d.ts +7 -2
- package/dist/lib/lanes/loop.js +169 -57
- package/dist/lib/mesh/live/flush.d.ts +7 -1
- package/dist/lib/mesh/live/flush.js +15 -7
- package/dist/lib/mesh/live/format-spool-line.d.ts +3 -1
- package/dist/lib/mesh/live/format-spool-line.js +3 -0
- package/dist/lib/mesh/live/index.d.ts +2 -0
- package/dist/lib/mesh/live/index.js +1 -0
- package/dist/lib/mesh/live/session-event.schema.json +9 -2
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/mesh/live/work-location.d.ts +30 -0
- package/dist/lib/mesh/live/work-location.js +86 -0
- package/dist/utils/version-gate.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { appendDropboxMessageAtPath } from "./dropbox.js";
|
|
5
|
+
export const LANE_SESSION_LINKS_FLAG = "lanes.session-links";
|
|
6
|
+
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
7
|
+
export class LaneLinkError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "LaneLinkError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function safeId(value) {
|
|
16
|
+
return Boolean(value && value !== "." && value !== ".." && ID_PATTERN.test(value));
|
|
17
|
+
}
|
|
18
|
+
export function resolveLinkSessionId(root, explicit, env = process.env) {
|
|
19
|
+
if (explicit !== undefined && explicit !== "") {
|
|
20
|
+
if (!safeId(explicit))
|
|
21
|
+
throw new LaneLinkError("invalid_session_id", `Invalid session id: ${explicit}`);
|
|
22
|
+
return explicit;
|
|
23
|
+
}
|
|
24
|
+
for (const key of ["HQ_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID", "GROK_SESSION_ID"]) {
|
|
25
|
+
const candidate = env[key]?.replace(/\s/g, "");
|
|
26
|
+
if (safeId(candidate))
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const candidate = fs.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/\s/g, "");
|
|
31
|
+
if (safeId(candidate))
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== "ENOENT")
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
throw new LaneLinkError("session_id_missing", "Cannot resolve this session's id; pass --session-id");
|
|
39
|
+
}
|
|
40
|
+
function rootDir(root) {
|
|
41
|
+
return path.join(root, "workspace", "lanes-links");
|
|
42
|
+
}
|
|
43
|
+
function pointerPath(root, sid) {
|
|
44
|
+
return path.join(rootDir(root), "by-session", sid);
|
|
45
|
+
}
|
|
46
|
+
function readPointer(root, sid) {
|
|
47
|
+
try {
|
|
48
|
+
const [role, link, name] = fs.readFileSync(pointerPath(root, sid), "utf8").trim().split(/\s+/);
|
|
49
|
+
if ((role !== "conductor" && role !== "child") || !safeId(link))
|
|
50
|
+
return null;
|
|
51
|
+
return role === "child" && safeId(name) ? { role, link, name } : { role, link };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === "ENOENT")
|
|
55
|
+
return null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function atomicJson(file, value) {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
62
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
63
|
+
fs.renameSync(tmp, file);
|
|
64
|
+
}
|
|
65
|
+
function readJson(file) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code === "ENOENT")
|
|
71
|
+
throw new LaneLinkError("link_not_found", `No such link: ${path.basename(path.dirname(file))}`);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function linkPath(root, link) {
|
|
76
|
+
if (!safeId(link))
|
|
77
|
+
throw new LaneLinkError("link_invalid", "Invalid or missing --link");
|
|
78
|
+
const target = path.join(rootDir(root), link);
|
|
79
|
+
if (!fs.existsSync(path.join(target, "meta.json")))
|
|
80
|
+
throw new LaneLinkError("link_not_found", `No such link: ${link}`);
|
|
81
|
+
if (readJson(path.join(target, "meta.json")).closed)
|
|
82
|
+
throw new LaneLinkError("link_closed", `The link was closed: ${link}`);
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
/** Cheap hook guard used before remote flag lookup or queue access. */
|
|
86
|
+
export function linkedSessionIdFromHookInput(input) {
|
|
87
|
+
try {
|
|
88
|
+
const payload = JSON.parse(input);
|
|
89
|
+
const value = payload.session_id ?? payload.sessionId;
|
|
90
|
+
return typeof value === "string" && safeId(value) ? value : "";
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function isLinkedSession(root, sid) {
|
|
97
|
+
return Boolean(safeId(sid) && readPointer(root, sid));
|
|
98
|
+
}
|
|
99
|
+
function activeChild(root, link, name) {
|
|
100
|
+
const childDir = path.join(rootDir(root), link, "children", name);
|
|
101
|
+
try {
|
|
102
|
+
const meta = readJson(path.join(childDir, "meta.json"));
|
|
103
|
+
const pointer = typeof meta.session_id === "string" ? readPointer(root, meta.session_id) : undefined;
|
|
104
|
+
return Boolean(pointer?.role === "child" && pointer.link === link && pointer.name === name);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function resolvePointer(root, sid, role, options) {
|
|
111
|
+
const pointer = readPointer(root, sid);
|
|
112
|
+
if (!pointer)
|
|
113
|
+
throw new LaneLinkError("session_unlinked", "This session is not part of a conduct link");
|
|
114
|
+
if (pointer.role !== role)
|
|
115
|
+
throw new LaneLinkError("link_role_invalid", `This session is a ${pointer.role} on ${pointer.link}, not a ${role}`);
|
|
116
|
+
return { link: options.link || pointer.link, child: options.child || pointer.name };
|
|
117
|
+
}
|
|
118
|
+
function queueDir(root, link, child) {
|
|
119
|
+
return path.join(rootDir(root), link, child ? "children" : "up", ...(child ? [child] : []), "inbox");
|
|
120
|
+
}
|
|
121
|
+
function sendQueue(root, link, child, text) {
|
|
122
|
+
const inbox = queueDir(root, link, child);
|
|
123
|
+
const pending = path.join(inbox, "pending");
|
|
124
|
+
fs.mkdirSync(pending, { recursive: true });
|
|
125
|
+
// Reuse hq monitor's session-inbox append primitive; the pending directory is
|
|
126
|
+
// still claimed as a whole below, keeping conduct-inbox's no-loss handoff.
|
|
127
|
+
const cleanText = text.replaceAll("\0", "").replaceAll("\u000b", "").replaceAll("\u000c", "").replaceAll("\r", "");
|
|
128
|
+
appendDropboxMessageAtPath(pending, { at: new Date().toISOString(), text: cleanText });
|
|
129
|
+
}
|
|
130
|
+
/** Claims pending files by renaming the whole queue, matching conduct-inbox's no-loss drain. */
|
|
131
|
+
export function drainLinkQueue(inbox) {
|
|
132
|
+
const pending = path.join(inbox, "pending");
|
|
133
|
+
const claimedRoot = path.join(inbox, "claimed");
|
|
134
|
+
// Recover claims interrupted before the delivery result was committed. This is
|
|
135
|
+
// at-least-once: a crash after provider output may replay the same message.
|
|
136
|
+
if (fs.existsSync(claimedRoot))
|
|
137
|
+
for (const oldClaim of fs.readdirSync(claimedRoot)) {
|
|
138
|
+
const oldPath = path.join(claimedRoot, oldClaim);
|
|
139
|
+
if (fs.existsSync(path.join(oldPath, ".consumed")))
|
|
140
|
+
continue;
|
|
141
|
+
const payload = path.join(oldPath, "inbox.jsonl");
|
|
142
|
+
if (!fs.existsSync(payload))
|
|
143
|
+
continue;
|
|
144
|
+
fs.mkdirSync(pending, { recursive: true });
|
|
145
|
+
const existing = path.join(pending, "inbox.jsonl");
|
|
146
|
+
const oldText = fs.readFileSync(payload, "utf8");
|
|
147
|
+
const currentText = fs.existsSync(existing) ? fs.readFileSync(existing, "utf8") : "";
|
|
148
|
+
fs.writeFileSync(existing, `${oldText}${currentText && !currentText.endsWith("\n") ? "\n" : ""}${currentText}`, { mode: 0o600 });
|
|
149
|
+
fs.writeFileSync(path.join(oldPath, ".consumed"), "recovered\n", { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
if (!fs.existsSync(pending) || fs.readdirSync(pending).length === 0)
|
|
152
|
+
return "";
|
|
153
|
+
fs.mkdirSync(claimedRoot, { recursive: true });
|
|
154
|
+
const claim = path.join(claimedRoot, `${Date.now()}-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
155
|
+
try {
|
|
156
|
+
fs.renameSync(pending, claim);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code === "ENOENT")
|
|
160
|
+
return "";
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
fs.mkdirSync(pending, { recursive: true });
|
|
164
|
+
const payload = path.join(claim, "inbox.jsonl");
|
|
165
|
+
if (!fs.existsSync(payload))
|
|
166
|
+
return "";
|
|
167
|
+
const messages = fs.readFileSync(payload, "utf8").split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
|
168
|
+
try {
|
|
169
|
+
const message = JSON.parse(line);
|
|
170
|
+
return typeof message.text === "string" && message.text ? [message.text] : [];
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}).join("\n");
|
|
176
|
+
fs.writeFileSync(path.join(claim, ".consumed"), "delivered\n", { mode: 0o600 });
|
|
177
|
+
return messages;
|
|
178
|
+
}
|
|
179
|
+
function countPending(inbox) {
|
|
180
|
+
const payload = path.join(inbox, "pending", "inbox.jsonl");
|
|
181
|
+
try {
|
|
182
|
+
return fs.readFileSync(payload, "utf8").split(/\r?\n/).filter(Boolean).length;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error.code === "ENOENT")
|
|
186
|
+
return 0;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function readText(options) {
|
|
191
|
+
if (options.textFile) {
|
|
192
|
+
try {
|
|
193
|
+
return fs.readFileSync(options.textFile, "utf8");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new LaneLinkError("text_file_not_found", `No such file: ${options.textFile}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return options.text ?? "";
|
|
200
|
+
}
|
|
201
|
+
function ensureText(options) {
|
|
202
|
+
const text = readText(options);
|
|
203
|
+
if (!text.trim())
|
|
204
|
+
throw new LaneLinkError("message_empty", "Refusing to send an empty message");
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
function setChildState(childDir, state, note, now) {
|
|
208
|
+
if (!["working", "blocked", "idle", "done"].includes(state)) {
|
|
209
|
+
throw new LaneLinkError("state_invalid", "--state must be working, blocked, idle or done");
|
|
210
|
+
}
|
|
211
|
+
const file = path.join(childDir, "meta.json");
|
|
212
|
+
atomicJson(file, { ...readJson(file), state, note: note ?? "", updated: now.toISOString() });
|
|
213
|
+
}
|
|
214
|
+
function companyForSession(root, sid) {
|
|
215
|
+
try {
|
|
216
|
+
const text = fs.readFileSync(path.join(root, "workspace", "sessions", sid, "meta.yaml"), "utf8");
|
|
217
|
+
return /^\s*company_slug:\s*["']?([^\s"'#]+)["']?\s*$/m.exec(text)?.[1] ?? "";
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function listChildren(root, dir) {
|
|
224
|
+
const children = path.join(dir, "children");
|
|
225
|
+
if (!fs.existsSync(children))
|
|
226
|
+
return [];
|
|
227
|
+
return fs.readdirSync(children, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
|
|
228
|
+
const cdir = path.join(children, entry.name);
|
|
229
|
+
const metaPath = path.join(cdir, "meta.json");
|
|
230
|
+
if (!fs.existsSync(metaPath))
|
|
231
|
+
return [];
|
|
232
|
+
const unread = countPending(path.join(cdir, "inbox"));
|
|
233
|
+
return [{ ...readJson(metaPath), undelivered: unread }];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function waitForReport(dir, timeoutSeconds, sleep) {
|
|
237
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 0)
|
|
238
|
+
throw new LaneLinkError("timeout_invalid", "--timeout must be whole seconds");
|
|
239
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
240
|
+
for (;;) {
|
|
241
|
+
if (!fs.existsSync(dir))
|
|
242
|
+
throw new LaneLinkError("link_closed", "The link was closed");
|
|
243
|
+
if (countPending(path.join(dir, "up", "inbox")) > 0) {
|
|
244
|
+
return { output: { pending: true }, exitCode: 0 };
|
|
245
|
+
}
|
|
246
|
+
if (Date.now() >= deadline)
|
|
247
|
+
return { output: { pending: false, timeout: true }, exitCode: 3 };
|
|
248
|
+
await sleep(Math.min(3000, Math.max(1, deadline - Date.now())));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function hookMessage(root, pointer) {
|
|
252
|
+
linkPath(root, pointer.link);
|
|
253
|
+
if (pointer.role === "conductor")
|
|
254
|
+
return { inbox: queueDir(root, pointer.link), role: "conductor" };
|
|
255
|
+
if (!pointer.name)
|
|
256
|
+
throw new LaneLinkError("link_pointer_invalid", "Child link pointer has no child name");
|
|
257
|
+
return { inbox: queueDir(root, pointer.link, pointer.name), role: "child", child: pointer.name };
|
|
258
|
+
}
|
|
259
|
+
export async function runLaneLink(subcommand, options) {
|
|
260
|
+
const now = options.now ?? (() => new Date());
|
|
261
|
+
const env = options.env ?? process.env;
|
|
262
|
+
const root = options.hqRoot;
|
|
263
|
+
let sid = "";
|
|
264
|
+
if (subcommand === "_deliver") {
|
|
265
|
+
sid = (() => {
|
|
266
|
+
let payload = {};
|
|
267
|
+
try {
|
|
268
|
+
payload = JSON.parse(options.input ?? "{}");
|
|
269
|
+
}
|
|
270
|
+
catch { /* malformed hooks are inert */ }
|
|
271
|
+
const value = payload.session_id ?? payload.sessionId;
|
|
272
|
+
return typeof value === "string" && safeId(value) ? value : "";
|
|
273
|
+
})();
|
|
274
|
+
}
|
|
275
|
+
const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
|
|
276
|
+
const links = rootDir(root);
|
|
277
|
+
if (subcommand === "_deliver") {
|
|
278
|
+
if (!sid || !["PostToolUse", "Stop", "SubagentStop"].includes(options.event ?? ""))
|
|
279
|
+
return {};
|
|
280
|
+
if (["Stop", "SubagentStop"].includes(options.event ?? "") && env.HQ_STOP_DECISION_DELIVERABLE === "0")
|
|
281
|
+
return {};
|
|
282
|
+
const pointer = readPointer(root, sid);
|
|
283
|
+
if (!pointer)
|
|
284
|
+
return {};
|
|
285
|
+
const target = hookMessage(root, pointer);
|
|
286
|
+
const messages = drainLinkQueue(target.inbox);
|
|
287
|
+
if (!messages)
|
|
288
|
+
return {};
|
|
289
|
+
const body = target.role === "conductor"
|
|
290
|
+
? `[conduct] Sessions you are conducting reported while you were working.\nEach report names the session it came from. Relay what the operator needs to know, and answer a session with: hq lanes link send --child <name> --text '...'\n\n${messages}`
|
|
291
|
+
: `[conduct] The session conducting you sent this while you were working.\nTreat it as a new instruction from the operator, taking precedence over your current task where the two conflict. When you have something to tell the conductor - a result, a blocker, a question - send it with: hq lanes link report --state <working|blocked|idle|done> --text '...'\n\n${messages}`;
|
|
292
|
+
return options.event === "PostToolUse"
|
|
293
|
+
? { output: { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: body } } }
|
|
294
|
+
: { output: { decision: "block", reason: body } };
|
|
295
|
+
}
|
|
296
|
+
if (subcommand === "open") {
|
|
297
|
+
const sessionId = requireSid();
|
|
298
|
+
const p = readPointer(root, sessionId);
|
|
299
|
+
if (p) {
|
|
300
|
+
if (p.role !== "conductor")
|
|
301
|
+
throw new LaneLinkError("session_already_child", `This session is already a child of ${p.link}; leave first`);
|
|
302
|
+
return { output: { link: p.link, join: `/conduct-join ${p.link}` } };
|
|
303
|
+
}
|
|
304
|
+
const date = now().toISOString().slice(0, 10).replaceAll("-", "");
|
|
305
|
+
const link = `cl-${date}-${randomBytes(3).toString("hex")}`;
|
|
306
|
+
const dir = path.join(links, link);
|
|
307
|
+
fs.mkdirSync(path.join(dir, "children"), { recursive: true });
|
|
308
|
+
fs.mkdirSync(path.join(dir, "up", "inbox", "pending"), { recursive: true });
|
|
309
|
+
atomicJson(path.join(dir, "meta.json"), { link, conductor_session: sessionId, conductor_host_session: options.hostSession ?? "", engine: options.engine || "claude", created: now().toISOString() });
|
|
310
|
+
const bySession = path.join(links, "by-session");
|
|
311
|
+
fs.mkdirSync(bySession, { recursive: true });
|
|
312
|
+
fs.writeFileSync(pointerPath(root, sessionId), `conductor ${link}\n`, { mode: 0o600, flag: "wx" });
|
|
313
|
+
return { output: { link, join: `/conduct-join ${link}` } };
|
|
314
|
+
}
|
|
315
|
+
if (subcommand === "join") {
|
|
316
|
+
const sessionId = requireSid();
|
|
317
|
+
const dir = linkPath(root, options.link);
|
|
318
|
+
if (readPointer(root, sessionId))
|
|
319
|
+
throw new LaneLinkError("session_already_linked", "This session is already linked; leave first");
|
|
320
|
+
const name = options.name || `s-${sessionId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8)}`;
|
|
321
|
+
if (!safeId(name) || name === "all")
|
|
322
|
+
throw new LaneLinkError("child_name_invalid", `Invalid or reserved --name: ${name}`);
|
|
323
|
+
const cdir = path.join(dir, "children", name);
|
|
324
|
+
try {
|
|
325
|
+
fs.mkdirSync(cdir);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error.code === "EEXIST")
|
|
329
|
+
throw new LaneLinkError("child_name_taken", `A child named ${name} is already on ${options.link}; pick another --name`);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
fs.mkdirSync(path.join(cdir, "inbox", "pending"), { recursive: true });
|
|
334
|
+
const at = now().toISOString();
|
|
335
|
+
atomicJson(path.join(cdir, "meta.json"), { name, session_id: sessionId, host_session: options.hostSession ?? "", engine: options.engine || "claude", title: options.title ?? "", company: companyForSession(root, sessionId), state: "idle", note: "", joined: at, updated: at });
|
|
336
|
+
fs.mkdirSync(path.join(links, "by-session"), { recursive: true });
|
|
337
|
+
fs.writeFileSync(pointerPath(root, sessionId), `child ${options.link} ${name}\n`, { mode: 0o600, flag: "wx" });
|
|
338
|
+
sendQueue(root, options.link, undefined, `[${name} joined] ${options.title || "untitled session"} (engine ${options.engine || "claude"}${companyForSession(root, sessionId) ? `, company ${companyForSession(root, sessionId)}` : ""})`);
|
|
339
|
+
const meta = readJson(path.join(dir, "meta.json"));
|
|
340
|
+
return { output: { link: options.link, name, conductor_host_session: meta.conductor_host_session ?? "" } };
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
fs.rmSync(pointerPath(root, sessionId), { force: true });
|
|
344
|
+
fs.rmSync(cdir, { recursive: true, force: true });
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (subcommand === "send") {
|
|
349
|
+
const resolved = options.link ? { link: options.link } : resolvePointer(root, requireSid(), "conductor", options);
|
|
350
|
+
const dir = linkPath(root, resolved.link);
|
|
351
|
+
const child = options.child;
|
|
352
|
+
if (!child)
|
|
353
|
+
throw new LaneLinkError("child_required", "--child <name|all> is required");
|
|
354
|
+
const body = ensureText(options);
|
|
355
|
+
const children = child === "all" ? fs.readdirSync(path.join(dir, "children"), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => activeChild(root, resolved.link, name)) : [child];
|
|
356
|
+
if (child !== "all" && (!safeId(child) || !activeChild(root, resolved.link, child)))
|
|
357
|
+
throw new LaneLinkError("child_not_found", `No active child named ${child} on ${resolved.link}`);
|
|
358
|
+
if (!children.length)
|
|
359
|
+
throw new LaneLinkError("children_empty", `No children on ${resolved.link}`);
|
|
360
|
+
for (const name of children)
|
|
361
|
+
sendQueue(root, resolved.link, name, body);
|
|
362
|
+
return { output: { queued: true, children: children.length } };
|
|
363
|
+
}
|
|
364
|
+
if (subcommand === "report") {
|
|
365
|
+
const resolved = resolvePointer(root, requireSid(), "child", options);
|
|
366
|
+
const dir = linkPath(root, resolved.link);
|
|
367
|
+
const child = resolved.child;
|
|
368
|
+
if (!child)
|
|
369
|
+
throw new LaneLinkError("link_pointer_invalid", "Child link pointer has no name");
|
|
370
|
+
const body = ensureText(options);
|
|
371
|
+
if (options.state)
|
|
372
|
+
setChildState(path.join(dir, "children", child), options.state, options.note, now());
|
|
373
|
+
sendQueue(root, resolved.link, undefined, `[from ${child}${options.state ? ` · ${options.state}` : ""}]\n${body}`);
|
|
374
|
+
const meta = readJson(path.join(dir, "meta.json"));
|
|
375
|
+
return { output: { queued: true, conductor_host_session: meta.conductor_host_session ?? "" } };
|
|
376
|
+
}
|
|
377
|
+
if (subcommand === "status") {
|
|
378
|
+
if (!options.state)
|
|
379
|
+
throw new LaneLinkError("state_required", "--state is required");
|
|
380
|
+
const resolved = resolvePointer(root, requireSid(), "child", options);
|
|
381
|
+
if (!resolved.child)
|
|
382
|
+
throw new LaneLinkError("link_pointer_invalid", "Child link pointer has no name");
|
|
383
|
+
setChildState(path.join(linkPath(root, resolved.link), "children", resolved.child), options.state, options.note, now());
|
|
384
|
+
return { output: { name: resolved.child, state: options.state } };
|
|
385
|
+
}
|
|
386
|
+
if (subcommand === "inbox" || subcommand === "read") {
|
|
387
|
+
const role = subcommand === "inbox" ? "child" : "conductor";
|
|
388
|
+
const pointer = role === "child" || !options.link
|
|
389
|
+
? resolvePointer(root, requireSid(), role, options)
|
|
390
|
+
: { link: options.link, child: options.child };
|
|
391
|
+
const resolved = options.link ? { ...pointer, link: options.link } : pointer;
|
|
392
|
+
const text = drainLinkQueue(queueDir(root, resolved.link, role === "child" ? resolved.child : undefined));
|
|
393
|
+
return { output: { messages: text } };
|
|
394
|
+
}
|
|
395
|
+
if (subcommand === "wait") {
|
|
396
|
+
const resolved = options.link ? { link: options.link } : resolvePointer(root, requireSid(), "conductor", options);
|
|
397
|
+
return await waitForReport(linkPath(root, resolved.link), options.timeout ?? 1800, options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))));
|
|
398
|
+
}
|
|
399
|
+
if (subcommand === "list") {
|
|
400
|
+
let link = options.link;
|
|
401
|
+
if (!link)
|
|
402
|
+
link = readPointer(root, requireSid())?.link;
|
|
403
|
+
if (!link)
|
|
404
|
+
return { output: { link: null, children: [] } };
|
|
405
|
+
const dir = linkPath(root, link);
|
|
406
|
+
const meta = readJson(path.join(dir, "meta.json"));
|
|
407
|
+
const unread = countPending(path.join(dir, "up", "inbox"));
|
|
408
|
+
return { output: { ...meta, unread_reports: unread, children: listChildren(root, dir) } };
|
|
409
|
+
}
|
|
410
|
+
if (subcommand === "leave") {
|
|
411
|
+
const sessionId = requireSid();
|
|
412
|
+
const pointer = readPointer(root, sessionId);
|
|
413
|
+
const resolved = resolvePointer(root, sessionId, "child", options);
|
|
414
|
+
const dir = linkPath(root, resolved.link);
|
|
415
|
+
const child = resolved.child;
|
|
416
|
+
sendQueue(root, resolved.link, undefined, `[${child} left the link]`);
|
|
417
|
+
setChildState(path.join(dir, "children", child), "done", "left", now());
|
|
418
|
+
fs.rmSync(pointerPath(root, sessionId), { force: true });
|
|
419
|
+
if (fs.existsSync(path.dirname(pointerPath(root, sessionId))) && fs.readdirSync(path.dirname(pointerPath(root, sessionId))).length === 0)
|
|
420
|
+
fs.rmdirSync(path.dirname(pointerPath(root, sessionId)));
|
|
421
|
+
return { output: { link: pointer.link, left: child } };
|
|
422
|
+
}
|
|
423
|
+
if (subcommand === "close") {
|
|
424
|
+
const pointer = resolvePointer(root, requireSid(), "conductor", options);
|
|
425
|
+
const resolved = options.link ? { link: options.link } : pointer;
|
|
426
|
+
if (resolved.link !== pointer.link)
|
|
427
|
+
throw new LaneLinkError("link_role_invalid", "A conductor can only close its own link");
|
|
428
|
+
const dir = linkPath(root, resolved.link);
|
|
429
|
+
const bySession = path.join(links, "by-session");
|
|
430
|
+
if (fs.existsSync(bySession))
|
|
431
|
+
for (const file of fs.readdirSync(bySession)) {
|
|
432
|
+
const [role, pointerLink] = fs.readFileSync(path.join(bySession, file), "utf8").trim().split(/\s+/);
|
|
433
|
+
if ((role === "conductor" || role === "child") && pointerLink === resolved.link)
|
|
434
|
+
fs.rmSync(path.join(bySession, file), { force: true });
|
|
435
|
+
}
|
|
436
|
+
if (fs.existsSync(bySession) && fs.readdirSync(bySession).length === 0)
|
|
437
|
+
fs.rmdirSync(bySession);
|
|
438
|
+
const meta = readJson(path.join(dir, "meta.json"));
|
|
439
|
+
atomicJson(path.join(dir, "meta.json"), { ...meta, closed: now().toISOString() });
|
|
440
|
+
return { output: { link: resolved.link, closed: true } };
|
|
441
|
+
}
|
|
442
|
+
throw new LaneLinkError("subcommand_unknown", `Unknown lanes link subcommand: ${subcommand}`);
|
|
443
|
+
}
|
|
444
|
+
/** Apply the default-on feature gate contract at the command boundary. */
|
|
445
|
+
export async function runLaneLinkWithFlag(subcommand, options, enabled) {
|
|
446
|
+
if (!enabled)
|
|
447
|
+
return subcommand === "_deliver" ? {} : { output: { error: "link_disabled" }, exitCode: 1 };
|
|
448
|
+
return runLaneLink(subcommand, options);
|
|
449
|
+
}
|
|
450
|
+
//# sourceMappingURL=links.js.map
|
package/dist/lib/lanes/loop.d.ts
CHANGED
|
@@ -128,6 +128,7 @@ interface StoredEnvelope {
|
|
|
128
128
|
envelope_id: string;
|
|
129
129
|
queued_at: string;
|
|
130
130
|
deadline_budget_seconds?: number;
|
|
131
|
+
stall_attempts?: number;
|
|
131
132
|
envelope: LoopEnvelope;
|
|
132
133
|
}
|
|
133
134
|
/** Validate both hq-cli phase envelopes and the conduct protocol §8 shape. */
|
|
@@ -155,10 +156,14 @@ export declare function interruptLoopQueue(runDir: string): {
|
|
|
155
156
|
withdrawn: string[];
|
|
156
157
|
already_picked_up: string[];
|
|
157
158
|
};
|
|
158
|
-
export declare function interruptLoopQueueForStory(runDir: string, storyId: string, phase?: string
|
|
159
|
+
export declare function interruptLoopQueueForStory(runDir: string, storyId: string, phase?: string, hooks?: {
|
|
160
|
+
beforePendingRead?: (file: string) => void;
|
|
161
|
+
beforePendingRename?: (file: string) => void;
|
|
162
|
+
}): {
|
|
159
163
|
withdrawn: string[];
|
|
160
164
|
already_picked_up: string[];
|
|
161
165
|
};
|
|
166
|
+
export declare function findLoopStallResult(runDir: string, questionId: string): LoopResult | undefined;
|
|
162
167
|
export declare function applyLoopStallAnswer(input: {
|
|
163
168
|
runDir: string;
|
|
164
169
|
questionId: string;
|
|
@@ -168,7 +173,7 @@ export declare function applyLoopStallAnswer(input: {
|
|
|
168
173
|
}): {
|
|
169
174
|
lane_id: string;
|
|
170
175
|
envelope_id: string;
|
|
171
|
-
action: "requeued" | "parked";
|
|
176
|
+
action: "requeued" | "parked" | "already_applied";
|
|
172
177
|
} | undefined;
|
|
173
178
|
export declare function loopStatus(runDir: string, laneId: string): LoopStatus;
|
|
174
179
|
export declare function setLoopControllerPid(runDir: string, laneId: string, pid: number | undefined): void;
|