@indigoai-us/hq-cli 5.125.0 → 5.127.0
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 +329 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +643 -6
- package/dist/command-catalog.generated.js +836 -6
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +11 -1
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +114 -0
- package/dist/commands/lanes.js +2969 -0
- package/dist/commands/mesh.js +287 -24
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/lanes/accounts.d.ts +103 -0
- package/dist/lib/lanes/accounts.js +318 -0
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +71 -0
- package/dist/lib/lanes/config.js +408 -0
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +49 -0
- package/dist/lib/lanes/index.js +27 -0
- package/dist/lib/lanes/liveness.d.ts +95 -0
- package/dist/lib/lanes/liveness.js +332 -0
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +116 -0
- package/dist/lib/lanes/registry.js +521 -0
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +68 -0
- package/dist/lib/lanes/store.js +745 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +535 -0
- package/dist/lib/lanes/types.js +350 -0
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +18 -13
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +3 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-up delivery decision, per provider and per lane state, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Two mechanisms exist. A live provider process drains the drop box on its
|
|
5
|
+
* next `PreToolUse` event (dropbox.ts). A Claude process is print-and-exit,
|
|
6
|
+
* so after it exits the only way to deliver is `claude -p --resume <sid>`,
|
|
7
|
+
* which continues the recorded session and keeps its prompt cache. Codex
|
|
8
|
+
* (0.155.1) has `codex exec resume <thread-id>`; the id is the `thread_id`
|
|
9
|
+
* of the `thread.started` event on the `--json` stdout stream, and the same
|
|
10
|
+
* id comes back on every resumed turn. Grok's CLI (1.0.34) has
|
|
11
|
+
* `--resume <id>` and its `--single --output-format json` result carries a
|
|
12
|
+
* `sessionId`, but no live run has shown `--single --resume` continue a
|
|
13
|
+
* session (the grok account was out of balance when this was written), so
|
|
14
|
+
* an exited grok lane keeps no delivery path until that is proven.
|
|
15
|
+
*
|
|
16
|
+
* Every dispatch site (`hq lanes message`, spawn, reconcile resume) asks this
|
|
17
|
+
* module rather than testing provider or state inline. A guard bound at one
|
|
18
|
+
* site and not another is the defect class this branch has already shipped.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { LanesError } from "./errors.js";
|
|
23
|
+
import { parseLaneProvider, } from "./types.js";
|
|
24
|
+
/** Which providers can continue an exited worker from a recorded session id. */
|
|
25
|
+
export const PROVIDER_SESSION_RESUME = {
|
|
26
|
+
claude: true,
|
|
27
|
+
codex: true,
|
|
28
|
+
grok: false,
|
|
29
|
+
};
|
|
30
|
+
export function providerSupportsSessionResume(provider) {
|
|
31
|
+
return PROVIDER_SESSION_RESUME[parseLaneProvider(provider)];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* States an operator message may resume from, given a session id.
|
|
35
|
+
*
|
|
36
|
+
* `done`: the worker finished its brief, wrote a done envelope and exited on
|
|
37
|
+
* its own. Nothing about the lane says the operator is finished with it; a
|
|
38
|
+
* follow-up is the normal next step, and the session is intact.
|
|
39
|
+
*
|
|
40
|
+
* `blocked`: the worker exited after writing a blocked envelope that names
|
|
41
|
+
* what it needs. An operator message is usually that missing input. The state
|
|
42
|
+
* is not terminal (spec §6.7) and nothing stopped the lane deliberately.
|
|
43
|
+
*
|
|
44
|
+
* Refused by `hq lanes message`:
|
|
45
|
+
* `awaiting_input` has its own path, `hq lanes questions answer`, and the
|
|
46
|
+
* reconcile pass resumes it once every question is answered. `killed` and
|
|
47
|
+
* `cancelled` were stopped on purpose by the operator; a message must not
|
|
48
|
+
* silently restart them. `failed` means the run ended without an envelope or
|
|
49
|
+
* never started; the operator has not seen why. `interrupted` exited with
|
|
50
|
+
* partial output and no envelope, so the lane's state was never closed
|
|
51
|
+
* cleanly; only the explicit `hq lanes resume` opt-in continues it
|
|
52
|
+
* (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
|
|
53
|
+
* resume.
|
|
54
|
+
*/
|
|
55
|
+
export const RESUMABLE_LANE_STATES = ["done", "blocked"];
|
|
56
|
+
export function isResumableLaneState(state) {
|
|
57
|
+
return RESUMABLE_LANE_STATES.includes(state);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* States `hq lanes resume` continues from. `interrupted` is the one a plain
|
|
61
|
+
* message refuses: the run ended without closing its state, and continuing
|
|
62
|
+
* it is a decision the operator makes by name. `done` and `blocked` are here
|
|
63
|
+
* so the one command covers every exited-with-a-session case.
|
|
64
|
+
*/
|
|
65
|
+
export const RESUME_COMMAND_STATES = ["interrupted", "done", "blocked"];
|
|
66
|
+
export function isResumeCommandState(state) {
|
|
67
|
+
return RESUME_COMMAND_STATES.includes(state);
|
|
68
|
+
}
|
|
69
|
+
export const LANE_DELIVERY_MECHANISMS = ["dropbox", "resume", "none"];
|
|
70
|
+
/**
|
|
71
|
+
* `dropbox`: a process is (or is about to be) alive to drain the inbox.
|
|
72
|
+
* `resume`: the process has exited, the provider can resume, the state allows
|
|
73
|
+
* it, and a session id was recorded from the worker's own result.
|
|
74
|
+
* `none`: nothing can deliver; the caller must refuse and write nothing.
|
|
75
|
+
*/
|
|
76
|
+
export function laneDeliveryMechanism(lane) {
|
|
77
|
+
if (lane.state === "queued" || lane.state === "running")
|
|
78
|
+
return "dropbox";
|
|
79
|
+
if (providerSupportsSessionResume(lane.provider) &&
|
|
80
|
+
isResumableLaneState(lane.state) &&
|
|
81
|
+
hasRecordedSessionId(lane)) {
|
|
82
|
+
return "resume";
|
|
83
|
+
}
|
|
84
|
+
return "none";
|
|
85
|
+
}
|
|
86
|
+
export function hasRecordedSessionId(lane) {
|
|
87
|
+
return (typeof lane.provider_session_id === "string" &&
|
|
88
|
+
lane.provider_session_id.trim().length > 0);
|
|
89
|
+
}
|
|
90
|
+
export function deliveryRefusalReason(lane) {
|
|
91
|
+
const state = lane.state;
|
|
92
|
+
switch (state) {
|
|
93
|
+
case "queued":
|
|
94
|
+
case "running":
|
|
95
|
+
return "";
|
|
96
|
+
case "awaiting_input":
|
|
97
|
+
return "Lane is awaiting_input. The worker has exited after writing an ask envelope. Use hq lanes questions answer <question-id>.";
|
|
98
|
+
case "done":
|
|
99
|
+
case "blocked": {
|
|
100
|
+
if (!providerSupportsSessionResume(lane.provider)) {
|
|
101
|
+
return `Lane is ${state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`;
|
|
102
|
+
}
|
|
103
|
+
if (!hasRecordedSessionId(lane)) {
|
|
104
|
+
return `Lane is ${state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`;
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
case "killed":
|
|
109
|
+
case "cancelled":
|
|
110
|
+
return `Lane is ${state}. The operator stopped it on purpose; a message does not restart it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
|
|
111
|
+
case "interrupted":
|
|
112
|
+
return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in: hq lanes resume <lane-id> [--text <message>].`;
|
|
113
|
+
case "failed":
|
|
114
|
+
return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
|
|
115
|
+
default: {
|
|
116
|
+
const _exhaustive = state;
|
|
117
|
+
return _exhaustive;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Reasons a resumable provider's exit recorded no session id. Never a fabricated value. */
|
|
122
|
+
export const SESSION_ID_ABSENT_REASONS = [
|
|
123
|
+
"worker_out_missing",
|
|
124
|
+
"no_result_json",
|
|
125
|
+
"no_session_id_field",
|
|
126
|
+
"no_thread_started_event",
|
|
127
|
+
];
|
|
128
|
+
function nonNegativeInt(value) {
|
|
129
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
130
|
+
? value
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parse the `claude -p --output-format json` result. stdout is appended
|
|
135
|
+
* across runs of the same lane, so the last `type: "result"` object is this
|
|
136
|
+
* run's. Only `session_id` and the two cache counters are read out; the
|
|
137
|
+
* prompt, the response body and everything else in the payload stay on disk.
|
|
138
|
+
*/
|
|
139
|
+
export function parseClaudeResultOutput(text) {
|
|
140
|
+
const lines = text.split("\n");
|
|
141
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
142
|
+
const line = (lines[i] ?? "").trim();
|
|
143
|
+
if (!line.startsWith("{"))
|
|
144
|
+
continue;
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(line);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
153
|
+
continue;
|
|
154
|
+
const rec = parsed;
|
|
155
|
+
if (rec.type !== "result")
|
|
156
|
+
continue;
|
|
157
|
+
const usage = rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
|
|
158
|
+
? rec.usage
|
|
159
|
+
: {};
|
|
160
|
+
const sessionId = typeof rec.session_id === "string" && rec.session_id.trim().length > 0
|
|
161
|
+
? rec.session_id.trim()
|
|
162
|
+
: null;
|
|
163
|
+
return {
|
|
164
|
+
session_id: sessionId,
|
|
165
|
+
session_id_absent: sessionId === null ? "no_session_id_field" : null,
|
|
166
|
+
cache_read_input_tokens: nonNegativeInt(usage.cache_read_input_tokens),
|
|
167
|
+
cache_creation_input_tokens: nonNegativeInt(usage.cache_creation_input_tokens),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
session_id: null,
|
|
172
|
+
session_id_absent: "no_result_json",
|
|
173
|
+
cache_read_input_tokens: null,
|
|
174
|
+
cache_creation_input_tokens: null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parse the `codex exec --json` event stream. The first event of a run is
|
|
179
|
+
* `{"type":"thread.started","thread_id":"<uuid>"}`; that id is what
|
|
180
|
+
* `codex exec resume <id>` takes, and a resumed run emits the same id again.
|
|
181
|
+
* Usage comes from the last `turn.completed` event: `cached_input_tokens`
|
|
182
|
+
* is the cache read and `cache_write_input_tokens` the cache creation.
|
|
183
|
+
* Nothing else on the stream is read out.
|
|
184
|
+
*/
|
|
185
|
+
export function parseCodexEventOutput(text) {
|
|
186
|
+
let threadId = null;
|
|
187
|
+
let usage = {};
|
|
188
|
+
let sawEvent = false;
|
|
189
|
+
for (const raw of text.split("\n")) {
|
|
190
|
+
const line = raw.trim();
|
|
191
|
+
if (!line.startsWith("{"))
|
|
192
|
+
continue;
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(line);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
201
|
+
continue;
|
|
202
|
+
const rec = parsed;
|
|
203
|
+
if (typeof rec.type !== "string")
|
|
204
|
+
continue;
|
|
205
|
+
sawEvent = true;
|
|
206
|
+
if (rec.type === "thread.started" && threadId === null) {
|
|
207
|
+
threadId =
|
|
208
|
+
typeof rec.thread_id === "string" && rec.thread_id.trim().length > 0
|
|
209
|
+
? rec.thread_id.trim()
|
|
210
|
+
: null;
|
|
211
|
+
}
|
|
212
|
+
else if (rec.type === "turn.completed") {
|
|
213
|
+
usage =
|
|
214
|
+
rec.usage && typeof rec.usage === "object" && !Array.isArray(rec.usage)
|
|
215
|
+
? rec.usage
|
|
216
|
+
: {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
session_id: threadId,
|
|
221
|
+
session_id_absent: threadId !== null ? null : sawEvent ? "no_thread_started_event" : "no_result_json",
|
|
222
|
+
cache_read_input_tokens: nonNegativeInt(usage.cached_input_tokens),
|
|
223
|
+
cache_creation_input_tokens: nonNegativeInt(usage.cache_write_input_tokens),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Per-provider stdout parser; null for a provider whose exit carries nothing to record. */
|
|
227
|
+
export function parseWorkerRunOutput(provider, text) {
|
|
228
|
+
switch (parseLaneProvider(provider)) {
|
|
229
|
+
case "claude":
|
|
230
|
+
return parseClaudeResultOutput(text);
|
|
231
|
+
case "codex":
|
|
232
|
+
return parseCodexEventOutput(text);
|
|
233
|
+
case "grok":
|
|
234
|
+
return null;
|
|
235
|
+
default:
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Read this run's result from the worker's own stdout. stdout is opened in
|
|
241
|
+
* append mode across runs, so only bytes past `workerOutOffset` (recorded in
|
|
242
|
+
* spawn.json at launch) are this run's. Returns null for a provider whose
|
|
243
|
+
* exit carries nothing to record, so those lanes keep behaving exactly as
|
|
244
|
+
* before.
|
|
245
|
+
*/
|
|
246
|
+
export function readWorkerRunResult(runDir, provider,
|
|
247
|
+
/** Bytes of worker.out that belong to earlier runs; parsing starts after them. */
|
|
248
|
+
workerOutOffset = 0) {
|
|
249
|
+
if (!providerSupportsSessionResume(provider))
|
|
250
|
+
return null;
|
|
251
|
+
const outPath = path.join(runDir, "logs", "worker.out");
|
|
252
|
+
let text;
|
|
253
|
+
try {
|
|
254
|
+
const bytes = fs.readFileSync(outPath);
|
|
255
|
+
text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const code = err.code;
|
|
259
|
+
if (code === "ENOENT") {
|
|
260
|
+
return {
|
|
261
|
+
session_id: null,
|
|
262
|
+
session_id_absent: "worker_out_missing",
|
|
263
|
+
cache_read_input_tokens: null,
|
|
264
|
+
cache_creation_input_tokens: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
process.stderr.write(`hq lanes: failed to read ${outPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
return parseWorkerRunOutput(provider, text);
|
|
271
|
+
}
|
|
272
|
+
export const LAUNCH_TRIGGERS = ["admit", "answered_questions", "message", "resume"];
|
|
273
|
+
/**
|
|
274
|
+
* Refusal for `hq lanes resume`, in precedence order: wrong state, provider
|
|
275
|
+
* without a resume path, no recorded session. Null when the resume can go.
|
|
276
|
+
*/
|
|
277
|
+
export function resumeCommandRefusal(lane) {
|
|
278
|
+
if (!isResumeCommandState(lane.state)) {
|
|
279
|
+
return {
|
|
280
|
+
error: "lane_not_resumable",
|
|
281
|
+
message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
|
|
282
|
+
? "use hq lanes questions answer <question-id>."
|
|
283
|
+
: lane.state === "queued" || lane.state === "running"
|
|
284
|
+
? "use hq lanes message for a live lane."
|
|
285
|
+
: "a lane in this state is not resumed."}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (!providerSupportsSessionResume(lane.provider)) {
|
|
289
|
+
return {
|
|
290
|
+
error: "resume_unsupported",
|
|
291
|
+
message: `Lane is ${lane.state}. Provider ${lane.provider} cannot resume an exited worker; there is no delivery path.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (!hasRecordedSessionId(lane)) {
|
|
295
|
+
return {
|
|
296
|
+
error: "no_provider_session",
|
|
297
|
+
message: `Lane is ${lane.state}. No provider session id was recorded from the worker's result, so the session cannot be resumed.`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Decide fresh-vs-resume for a spawn. Admission always starts fresh with the
|
|
304
|
+
* brief. Answered questions resume the recorded session when the provider
|
|
305
|
+
* can, else start fresh with the answers file (today's behaviour). An operator
|
|
306
|
+
* message can only be a resume, and only when `laneDeliveryMechanism` says so.
|
|
307
|
+
* `hq lanes resume` is also always a resume, gated by `resumeCommandRefusal`.
|
|
308
|
+
*/
|
|
309
|
+
export function planLaneLaunch(lane, trigger) {
|
|
310
|
+
switch (trigger.kind) {
|
|
311
|
+
case "admit":
|
|
312
|
+
return { mode: "fresh" };
|
|
313
|
+
case "answered_questions":
|
|
314
|
+
if (providerSupportsSessionResume(lane.provider) && hasRecordedSessionId(lane)) {
|
|
315
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
316
|
+
}
|
|
317
|
+
return { mode: "fresh" };
|
|
318
|
+
case "message": {
|
|
319
|
+
if (laneDeliveryMechanism(lane) !== "resume" || !hasRecordedSessionId(lane)) {
|
|
320
|
+
throw new LanesError("lane_not_accepting_messages", deliveryRefusalReason(lane) || `Lane is ${lane.state} and cannot be resumed`, { state: lane.state, provider: lane.provider });
|
|
321
|
+
}
|
|
322
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
323
|
+
}
|
|
324
|
+
case "resume": {
|
|
325
|
+
const refusal = resumeCommandRefusal(lane);
|
|
326
|
+
if (refusal !== null || !hasRecordedSessionId(lane)) {
|
|
327
|
+
throw new LanesError(refusal?.error ?? "no_provider_session", refusal?.message ?? `Lane is ${lane.state} and has no recorded provider session`, { state: lane.state, provider: lane.provider });
|
|
328
|
+
}
|
|
329
|
+
return { mode: "resume", session_id: lane.provider_session_id };
|
|
330
|
+
}
|
|
331
|
+
default: {
|
|
332
|
+
const _exhaustive = trigger;
|
|
333
|
+
return _exhaustive;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Lane states a launch may claim `running` from, per trigger. */
|
|
338
|
+
export function claimableStatesFor(trigger) {
|
|
339
|
+
switch (trigger.kind) {
|
|
340
|
+
case "admit":
|
|
341
|
+
return ["queued"];
|
|
342
|
+
case "answered_questions":
|
|
343
|
+
return ["awaiting_input"];
|
|
344
|
+
case "message":
|
|
345
|
+
return RESUMABLE_LANE_STATES;
|
|
346
|
+
case "resume":
|
|
347
|
+
return RESUME_COMMAND_STATES;
|
|
348
|
+
default: {
|
|
349
|
+
const _exhaustive = trigger;
|
|
350
|
+
return _exhaustive;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=delivery.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane drop box: append-only inbox.jsonl plus exactly-once drain (spec §6.5).
|
|
3
|
+
*
|
|
4
|
+
* Consume is `rename(inbox.jsonl → draining/<unique>)` — atomic on this box.
|
|
5
|
+
* Two overlapping drainers cannot both claim the same bytes; a crash after
|
|
6
|
+
* rename leaves the messages in draining/ for the next drain to recover.
|
|
7
|
+
* A read-then-truncate is not this mechanism and loses or duplicates under
|
|
8
|
+
* overlap.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import { type LaneRecord, type LaneState, type QuestionRecord } from "./types.js";
|
|
12
|
+
export declare const DROPBOX_DIR = "dropbox";
|
|
13
|
+
export declare const INBOX_NAME = "inbox.jsonl";
|
|
14
|
+
export declare const DRAINING_DIR = "draining";
|
|
15
|
+
export declare const DELIVERED_DIR = "delivered";
|
|
16
|
+
export declare const INFLIGHT_DIR = "inflight";
|
|
17
|
+
export interface DropboxMessage {
|
|
18
|
+
at?: string;
|
|
19
|
+
message_id?: string;
|
|
20
|
+
kind?: string;
|
|
21
|
+
question_id?: string;
|
|
22
|
+
choice?: string;
|
|
23
|
+
text: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface DrainDeps {
|
|
27
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
28
|
+
renameSync: typeof fs.renameSync;
|
|
29
|
+
readFileSync: typeof fs.readFileSync;
|
|
30
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
31
|
+
unlinkSync: typeof fs.unlinkSync;
|
|
32
|
+
existsSync: typeof fs.existsSync;
|
|
33
|
+
readdirSync: typeof fs.readdirSync;
|
|
34
|
+
rmdirSync: typeof fs.rmdirSync;
|
|
35
|
+
kill: (pid: number, signal?: NodeJS.Signals | 0) => void;
|
|
36
|
+
pid: number;
|
|
37
|
+
unique: () => string;
|
|
38
|
+
readStarttime?: (pid: number) => number | null;
|
|
39
|
+
/** Test seam: after observing inbox bytes, before the consume rename. */
|
|
40
|
+
afterObserveInbox?: () => void;
|
|
41
|
+
/** Test seam: runs after this process has claimed and read its bytes. */
|
|
42
|
+
afterRead?: () => void;
|
|
43
|
+
}
|
|
44
|
+
export declare function mintClaimId(): string;
|
|
45
|
+
export declare function assertSafeLaneId(laneId: string): void;
|
|
46
|
+
export declare function dropboxDir(hqRoot: string, laneId: string): string;
|
|
47
|
+
export declare function dropboxInboxPath(hqRoot: string, laneId: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Worker tool-call liveness (watcher.ts reads it). A Claude worker run with
|
|
50
|
+
* `--output-format json` writes nothing to worker.out until it exits, so the
|
|
51
|
+
* only thing it does observably while working is call tools. Every tool call
|
|
52
|
+
* runs this drain hook, and the hook records the call here before it touches
|
|
53
|
+
* the drop box. The file's mtime is the signal; the body is for humans.
|
|
54
|
+
*/
|
|
55
|
+
export declare const TOOL_ACTIVITY_NAME = "tool-activity.json";
|
|
56
|
+
export declare function toolActivityPath(hqRoot: string, laneId: string): string;
|
|
57
|
+
export interface ToolActivityRecord {
|
|
58
|
+
at: string;
|
|
59
|
+
hook_pid: number;
|
|
60
|
+
hook_event: "PreToolUse";
|
|
61
|
+
}
|
|
62
|
+
export declare function recordToolActivity(hqRoot: string, laneId: string, at?: string): ToolActivityRecord;
|
|
63
|
+
export declare function readToolActivity(hqRoot: string, laneId: string): ToolActivityRecord | null;
|
|
64
|
+
export declare function appendDropboxMessage(hqRoot: string, laneId: string, message: DropboxMessage): DropboxMessage;
|
|
65
|
+
/**
|
|
66
|
+
* Drain this lane's inbox exactly once. Concurrent callers: one rename wins;
|
|
67
|
+
* the loser sees ENOENT and returns whatever it separately claimed (usually
|
|
68
|
+
* nothing). Leftovers in draining/ and dead inflight dirs are recovered.
|
|
69
|
+
*/
|
|
70
|
+
export declare function drainDropbox(hqRoot: string, laneId: string, depsInput?: Partial<DrainDeps>): DropboxMessage[];
|
|
71
|
+
export declare function formatOperatorInstruction(messages: DropboxMessage[]): string;
|
|
72
|
+
export declare function hookAdditionalContextJson(messages: DropboxMessage[]): string;
|
|
73
|
+
export interface DrainHookResult {
|
|
74
|
+
exitCode: number;
|
|
75
|
+
stdout: string;
|
|
76
|
+
stderr: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Hook protocol. HQ_LANE_ID absent or empty → exit 0, empty stdout, empty stderr.
|
|
80
|
+
* That no-op is load-bearing: this hook is registered in the grok home and runs
|
|
81
|
+
* on every session on the box.
|
|
82
|
+
*/
|
|
83
|
+
export declare function runDrainHook(env: NodeJS.ProcessEnv): DrainHookResult;
|
|
84
|
+
/** Spec §6.5: only a still-running lane gets drop-box delivery. */
|
|
85
|
+
export declare function laneAcceptsDropboxDelivery(lane: Pick<LaneRecord, "state">): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Operator `hq lanes message` delivery. Queued is accepted because the
|
|
88
|
+
* worker that will spawn drains the inbox on its first tool event.
|
|
89
|
+
* `laneAcceptsDropboxDelivery` refuses queued, so it is the wrong guard here.
|
|
90
|
+
*/
|
|
91
|
+
export declare function laneAcceptsOperatorMessage(lane: Pick<LaneRecord, "state">): boolean;
|
|
92
|
+
export declare function operatorMessageRefusalReason(state: LaneState): string;
|
|
93
|
+
/** Delivery target is the lane_id on the question record. Never a project/story lookup. */
|
|
94
|
+
export declare function laneIdForAnswerDelivery(question: Pick<QuestionRecord, "lane_id">): string;
|
|
95
|
+
export declare function answerDropboxMessage(question: Pick<QuestionRecord, "question_id" | "lane_id">, choice: string, at: string, messageId: string): DropboxMessage;
|
|
96
|
+
//# sourceMappingURL=dropbox.d.ts.map
|