@indigoai-us/hq-cli 5.126.0 → 5.128.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 +218 -0
- 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 +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/core-utils/sentry-report.d.ts +2 -0
- package/dist/lib/core-utils/sentry-report.js +47 -7
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- 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 +2 -2
- 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 +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- 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 +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- 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/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 +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,224 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.128.0] — 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Expected hook-timeout warnings are counted as Sentry metrics, so a slow hook
|
|
10
|
+
does not create its own Sentry issue. Genuine timeouts use stable grouping.
|
|
11
|
+
|
|
12
|
+
## [5.127.0] — 2026-09-22
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Every composed lane brief and every resume turn carries a foreground-only
|
|
17
|
+
rule after the standing authorization: run every command, test suite and
|
|
18
|
+
check in the foreground and wait for it; a turn that ends before the envelope
|
|
19
|
+
is written is classified interrupted.
|
|
20
|
+
- The spec §4 envelope repair turn now runs for grok as well. A provider with
|
|
21
|
+
no session resume gets one fresh single-turn invocation on the same account
|
|
22
|
+
carrying the schema, the parser error and the raw failed output. The lane
|
|
23
|
+
records `envelope_repair.mode` as `resume` (claude, codex) or `fresh_turn`
|
|
24
|
+
(grok) and `hq lanes show` prints it as `envelope_repair_mode`.
|
|
25
|
+
- Lane records now reject omissions of required `hq-lane.v1` fields, and lane
|
|
26
|
+
mutations enforce the durable field-writer ownership map at the store boundary.
|
|
27
|
+
The map grants `reconcile` the quota fields it writes during dead-host
|
|
28
|
+
recovery, the outpost pickup account bind carries the `spawn` writer, and
|
|
29
|
+
`terminal_details` and `outpost_command_id` are typed and owned. The writer
|
|
30
|
+
argument is a required writer name; the function-form call that skipped the
|
|
31
|
+
ownership check is gone. The updater receives a deep clone and the check
|
|
32
|
+
compares against the pre-update snapshot, so mutating the argument in place
|
|
33
|
+
cannot bypass ownership.
|
|
34
|
+
- Answer notes reach a resumed lane: `answered-questions.json` carries the
|
|
35
|
+
operator's `--notes` text read from the lane's own body store, and the resume
|
|
36
|
+
prompt renders each answer as its choice followed by the notes.
|
|
37
|
+
- Every lane brief and every resume turn carries a standing authorization
|
|
38
|
+
paragraph: the brief authorises completing every item it lists, and a
|
|
39
|
+
`decision: ask` envelope is only for a decision the brief does not settle.
|
|
40
|
+
- A question whose options are only continue and stop is recorded with
|
|
41
|
+
`question_kind: "continue"` and `hq lanes questions list` prints it with
|
|
42
|
+
`flag: "continue-loop"`. Nothing answers it automatically.
|
|
43
|
+
- `hq lanes list` reports `senior_lost: true` for a running lane whose senior
|
|
44
|
+
session is absent, ended, or lost, without mutating the lane.
|
|
45
|
+
- `hq lanes questions answer --freeform <text>` answers a question that offered
|
|
46
|
+
options with free text and records `answer_kind: freeform`.
|
|
47
|
+
- `hq lanes resume <lane-id> [--text <message>]` resumes an `interrupted`,
|
|
48
|
+
`done` or `blocked` lane on its recorded provider session and records
|
|
49
|
+
`resumed_from`. Codex lanes record their session id at spawn and resume with
|
|
50
|
+
`codex exec resume`, so `hq lanes message` and the envelope repair turn now
|
|
51
|
+
work for exited Codex lanes as they do for Claude.
|
|
52
|
+
- Lanes now report to the work mesh from the watcher, not only at create. A
|
|
53
|
+
lane that finishes posts a completion event and moves its story to review;
|
|
54
|
+
one that stops to ask posts the question ids; one that is blocked posts the
|
|
55
|
+
reason. While a lane is working, the watcher posts a progress event when it
|
|
56
|
+
sees a new tool call or new output, at most once per five minutes per lane
|
|
57
|
+
(`providers.<provider>.progress_event_min_interval_ms` in
|
|
58
|
+
`personal/lanes/config.json`), and never for silence. The watcher signs in
|
|
59
|
+
the same way the session hooks do and never opens a browser; when it cannot
|
|
60
|
+
reach the mesh it writes `mesh_event_failed` to the lane's watcher log and
|
|
61
|
+
the lane's state is unchanged. On the wire, lane events use the mesh's
|
|
62
|
+
existing activity, question, and note kinds, because the server rejects
|
|
63
|
+
unknown kinds.
|
|
64
|
+
- A lane whose final output is not a valid envelope gets one repair turn: the
|
|
65
|
+
same session is resumed with the schema, the parser error, and the raw text,
|
|
66
|
+
and asked for the corrected JSON only. If that also fails the lane is
|
|
67
|
+
`failed` with reason `envelope_invalid`, and `envelope_repair` on the record
|
|
68
|
+
carries both errors. Codex and Grok lanes cannot be resumed after exit, so
|
|
69
|
+
they still fail on the first parse error; the record says why no repair ran.
|
|
70
|
+
- A worker with `verification.approval_required` or `human_checkpoints` now
|
|
71
|
+
briefs its lane to stop at each checkpoint with a `decision: ask` envelope
|
|
72
|
+
naming the checkpoint. The resume prompt after the answer carries the answer
|
|
73
|
+
inline as well as naming the answers file.
|
|
74
|
+
- `hq lanes ask` retries delivery three times, waiting 5 s then 15 s, when the
|
|
75
|
+
CLI is being replaced under it. On final failure it writes
|
|
76
|
+
`ask_delivery_failed.json` and returns `ask_delivery_failed`; the lane's
|
|
77
|
+
state is never changed.
|
|
78
|
+
- You can hand a piece of work off to run on its own, and it keeps going after
|
|
79
|
+
you close the window. If it hits something only you can decide, it stops and
|
|
80
|
+
asks instead of guessing, and picks up again the moment you answer — you no
|
|
81
|
+
longer have to sit and watch it. If it goes quiet for twenty minutes, you are
|
|
82
|
+
told. Work sent to a second machine that would have landed back on the one
|
|
83
|
+
that asked is stopped before it starts.
|
|
84
|
+
- `hq lanes create --model` records the model on the lane and passes the
|
|
85
|
+
provider's own flag (`--model` for Claude, `-m` for Codex and Grok).
|
|
86
|
+
Account and provider `model` keys in `personal/lanes/config.json` are
|
|
87
|
+
defaults; `--model` wins. An empty `--model` is refused.
|
|
88
|
+
- Lane envelopes may include `story_complete` and `story_complete_reason`.
|
|
89
|
+
`story_complete true` requires `story_status done`; `false` forbids it.
|
|
90
|
+
Both are copied onto the lane record when an envelope is ingested.
|
|
91
|
+
- `hq lanes create --effort <level>` sets reasoning effort per provider
|
|
92
|
+
(`--effort` for Claude, `-c model_reasoning_effort` for Codex,
|
|
93
|
+
`--reasoning-effort` for Grok), validated against each provider's own set.
|
|
94
|
+
`effort` on an account or provider in `personal/lanes/config.json` is the
|
|
95
|
+
default; with nothing set, an Opus or Fable model runs at `low`.
|
|
96
|
+
- A resumed Claude lane whose last message is older than `compact_after_ms`
|
|
97
|
+
(default one hour) is compacted with `/compact` before the resume, and the
|
|
98
|
+
lane records the outcome and the before/after context size. `hq lanes
|
|
99
|
+
message --no-compact` skips it once. `accounts list` and `show` report
|
|
100
|
+
model, effort and the last compaction.
|
|
101
|
+
- `--worker` resolves workers from `core/workers`, `personal/workers`,
|
|
102
|
+
`companies/<co>/workers`, and packages mounted by `hq core scan-packages`.
|
|
103
|
+
A worker id present in two trees is refused; a pack worker that is declared
|
|
104
|
+
but not mounted is `package_workers_unmounted`.
|
|
105
|
+
- A provider 429 is recorded per account and model in
|
|
106
|
+
`personal/lanes/quota.json` (`account_quota_exhausted` with the reset time,
|
|
107
|
+
or `account_model_unavailable` when a model needs credits). The balancer
|
|
108
|
+
skips only what cannot run and never rewrites the operator's `enabled`.
|
|
109
|
+
- `hq mesh story` accepts `--assignee <uid|me|none>` with or without `--status`.
|
|
110
|
+
`me` uses the authenticated person or agent and refuses the synthetic session
|
|
111
|
+
identity. `none` clears the assignee. The command does not send an actor.
|
|
112
|
+
- `hq mesh project set <projectId> --company <slug>` now reads and merges the
|
|
113
|
+
current Board view before replacing it, so a name or description update keeps
|
|
114
|
+
existing stories and repository bindings. It accepts repository bindings from
|
|
115
|
+
PRD `metadata.repos`, `--repos-json`, or repeatable `--repo <path[@branch]>`.
|
|
116
|
+
`hq mesh project register <projectId> --company <slug>` registers the Board's
|
|
117
|
+
deterministic project channel and genesis thread without raw API calls.
|
|
118
|
+
- A work-context session-state lock or directory creation failure is reported
|
|
119
|
+
as not tracking (exit 3) instead of an uncaught filesystem error.
|
|
120
|
+
- The Work Mesh daemon now distinguishes an explicit device-default membership
|
|
121
|
+
revocation from malformed or unavailable membership lookups. Temporary
|
|
122
|
+
failures preserve attribution and retry later; explicit denials clear only
|
|
123
|
+
the unchanged device-default binding through the shared session-state lock,
|
|
124
|
+
so concurrent rebinds are preserved. Transcript-start discovery also leaves
|
|
125
|
+
unavailable lookups unstarted and retries with bounded backoff.
|
|
126
|
+
|
|
127
|
+
### Fixed
|
|
128
|
+
|
|
129
|
+
- `hq agents status` no longer tells you to sign in to a brain that is already
|
|
130
|
+
signed in. A device pairing file can outlive the sign-in it started, so the
|
|
131
|
+
box sign-in probe now decides whether the prompt is shown. Grok and Codex
|
|
132
|
+
prompts are matched to their own brain slot; a Claude prompt has no slot and
|
|
133
|
+
is unaffected.
|
|
134
|
+
- Lane watcher mesh calls now time out, progress events only follow new
|
|
135
|
+
activity, and a persisted progress timestamp prevents duplicate events after
|
|
136
|
+
a watcher restart. Invalid-envelope repair is limited to one attempt even
|
|
137
|
+
when the repair writes a different envelope filename.
|
|
138
|
+
- Completion mesh delivery attempts the thread event and story review update
|
|
139
|
+
independently. Repair prompts cap inlined malformed JSON while retaining the
|
|
140
|
+
raw envelope path.
|
|
141
|
+
|
|
142
|
+
- `hq lanes reconcile install --enable` now enables the systemd user timer; the
|
|
143
|
+
generated unit uses `KillMode=process` so admitted lanes survive the pass, and
|
|
144
|
+
its PATH carries the directories of the resolved provider binaries. A provider
|
|
145
|
+
binary missing from PATH at spawn leaves the lane `queued` with
|
|
146
|
+
`provider_binary_missing` instead of failing it.
|
|
147
|
+
- A provider binary missing from PATH re-queues a lane only on admission. An
|
|
148
|
+
answered-question or operator-message resume keeps the lane's recorded state
|
|
149
|
+
and returns `provider_binary_missing` with the searched PATH, so the next
|
|
150
|
+
reconcile resumes the recorded session instead of starting a fresh run and
|
|
151
|
+
the operator message is not lost.
|
|
152
|
+
- `hq lanes reconcile install` succeeds on a box with only one provider
|
|
153
|
+
installed. The unit PATH carries the providers that resolve; the result lists
|
|
154
|
+
`providers_resolved`, `providers_unresolved`, and a `warnings` line per
|
|
155
|
+
missing provider. It refuses with `provider_binary_unresolved` only when no
|
|
156
|
+
enabled provider resolves.
|
|
157
|
+
- `hq lanes questions answer` treats a whitespace-only `--freeform` as absent,
|
|
158
|
+
so `--choice` is validated against the options and recorded as an option
|
|
159
|
+
answer instead of an empty freeform answer.
|
|
160
|
+
- A synced question conflict sidecar whose `answer_history` is not an array is
|
|
161
|
+
skipped like any other unreadable sidecar instead of failing every read of
|
|
162
|
+
the question.
|
|
163
|
+
- `hq lanes resume` now keeps terminal lanes terminal when account binding
|
|
164
|
+
fails, records project or story attribution without requiring `--text`, and
|
|
165
|
+
rejects a concurrent resume that has already become running. Blocked mesh
|
|
166
|
+
notes use the accepted `audit` note kind and include their reason in text so
|
|
167
|
+
the server preserves the marker.
|
|
168
|
+
|
|
169
|
+
- `hq agents list` shows an agent whose removal is under way as `removing`
|
|
170
|
+
rather than the runtime status it had before the removal started. An agent
|
|
171
|
+
that never finished setting up and is then removed used to keep reading
|
|
172
|
+
`provisioning` until the teardown completed, so a removal that stalled
|
|
173
|
+
looked like a removal that was never accepted. A finished teardown reads
|
|
174
|
+
`removed`; every other agent is unchanged.
|
|
175
|
+
- `hq lanes accounts list` treats Claude as logged in when
|
|
176
|
+
`.credentials.json` exists. Missing Claude credentials are reported as
|
|
177
|
+
unknown, not logged out. Codex and Grok still use `auth.json`.
|
|
178
|
+
- With no `--account`, a lane is assigned the least-loaded enabled
|
|
179
|
+
logged-in account rather than `default_account`.
|
|
180
|
+
- A lane watcher now writes a JSON line to `logs/watcher.out` for each
|
|
181
|
+
decision that changes what it believes: a liveness transition, a silence
|
|
182
|
+
deadline, an envelope appearing, the exit classification, and the
|
|
183
|
+
terminal record write. `hq lanes logs` reports each stream's path, size
|
|
184
|
+
in bytes, and lines, so an empty log file is distinguishable from a
|
|
185
|
+
missing lane. `watcher.out` is capped at 256 KiB by dropping the oldest
|
|
186
|
+
complete lines. `hq lanes message` refuses a lane that will never read
|
|
187
|
+
the drop box (`awaiting_input`, `blocked`, and every terminal state) and
|
|
188
|
+
still accepts a queued lane. After the HQ root is resolved, `hq lanes`
|
|
189
|
+
chdirs into it; relative `--brief-file` and `--payload-file` paths still
|
|
190
|
+
resolve against the caller's original directory.
|
|
191
|
+
- `hq lanes` no longer treats a git worktree's `.claude` folder as the HQ
|
|
192
|
+
root, and it honours `$HQ_ROOT` the same way the watcher and reconcile
|
|
193
|
+
timer already did. Listing or creating a lane from a worktree talks to
|
|
194
|
+
the real store, or fails out loud — it does not return an empty success
|
|
195
|
+
against a directory nothing else reads.
|
|
196
|
+
- Creating a lane with `--outpost` now pushes it through `hq outposts exec`
|
|
197
|
+
instead of leaving a queued row that no machine will pick up. If the
|
|
198
|
+
Outpost is unreachable, the lane is failed, not queued.
|
|
199
|
+
- A lane that finishes by asking a question now actually queues that question,
|
|
200
|
+
so someone can answer it and the lane can continue. Stopping a lane now
|
|
201
|
+
stops the process, not only the record. The drain hook that delivers
|
|
202
|
+
mid-run answers is installed into the account home instead of sitting unused
|
|
203
|
+
in the package. The periodic reconcile timer now names this HQ tree, so it
|
|
204
|
+
does not wander into `$HOME/.claude`. A live lane heartbeats from its
|
|
205
|
+
watcher; a dead watcher is started again; a reused PID is not treated as
|
|
206
|
+
the worker still running; a malformed drop-box line no longer wedges every
|
|
207
|
+
tool call.
|
|
208
|
+
- `hq mesh start` now resolves its claimant from the token that authenticates
|
|
209
|
+
the request before considering a same-identity cache entry. This prevents an
|
|
210
|
+
agent from claiming work as a cached human and stops locally before creating
|
|
211
|
+
a thread when no authenticated claimant can be established.
|
|
212
|
+
|
|
213
|
+
- `hq mesh context backfill-held` no longer reports `reconciled: 0` when
|
|
214
|
+
`--dry-run` predicts non-zero reconcilable sessions. The check that decides
|
|
215
|
+
whether reconcile produced a durable company binding now recognises a
|
|
216
|
+
persisted `companySlug` as sufficient — the daemon resolves the uid on its
|
|
217
|
+
next retry — instead of requiring `companyUid`, which is absent when the
|
|
218
|
+
reconciler runs without a network membership call. Sessions that have no
|
|
219
|
+
company evidence at all now appear in a separate `noEvidence` counter so
|
|
220
|
+
operators can tell them apart from sessions that were genuinely reconciled
|
|
221
|
+
as slug-only bindings.
|
|
222
|
+
|
|
5
223
|
## [5.126.0] — 2026-09-21
|
|
6
224
|
|
|
7
225
|
### Fixed
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lane-resolving drop-box drain (spec §6.5). Zero imports so a copy under
|
|
4
|
+
* $GROK_HOME/hooks/ runs without the hq-cli package.
|
|
5
|
+
*
|
|
6
|
+
* Exactly-once: rename(inbox.jsonl → inflight/<pid>.<starttime>.<unique>/inbox.jsonl).
|
|
7
|
+
* A read-then-truncate is not this mechanism.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
const laneId = process.env.HQ_LANE_ID ?? "";
|
|
14
|
+
if (laneId === "") {
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hqRoot = process.env.HQ_ROOT ?? "";
|
|
19
|
+
if (hqRoot === "") {
|
|
20
|
+
process.stderr.write("hq-lanes-drain: HQ_ROOT is unset while HQ_LANE_ID is set\n");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
laneId.includes("/") ||
|
|
26
|
+
laneId.includes("\\") ||
|
|
27
|
+
laneId.includes("\0") ||
|
|
28
|
+
laneId.includes("..")
|
|
29
|
+
) {
|
|
30
|
+
process.stderr.write("hq-lanes-drain: HQ_LANE_ID is not a single path segment\n");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unique() {
|
|
35
|
+
return `${process.hrtime.bigint()}.${randomBytes(4).toString("hex")}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readStarttime(pid) {
|
|
39
|
+
try {
|
|
40
|
+
const text = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
41
|
+
const open = text.indexOf("(");
|
|
42
|
+
const close = text.lastIndexOf(")");
|
|
43
|
+
if (open <= 0 || close <= open) return 0;
|
|
44
|
+
const rest = text.slice(close + 1).trim().split(/\s+/);
|
|
45
|
+
// rest begins at field 3, so field N is rest[N - 3]. starttime is field 22
|
|
46
|
+
// -> rest[19]. rest[21] is field 24 (rss) and must not be used here.
|
|
47
|
+
const n = Number(rest[19]);
|
|
48
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
49
|
+
} catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tryRename(src, dest) {
|
|
55
|
+
try {
|
|
56
|
+
fs.renameSync(src, dest);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err && err.code === "ENOENT") return false;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function listNames(dir) {
|
|
65
|
+
try {
|
|
66
|
+
return fs.readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err && err.code === "ENOENT") return [];
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
`hq-lanes-drain: failed to read ${dir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pidAlive(pid, starttime) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err && err.code === "ESRCH") return false;
|
|
81
|
+
if (!err || err.code !== "EPERM") throw err;
|
|
82
|
+
}
|
|
83
|
+
// Unknown starttime (0) is not proof of liveness: recover the claim.
|
|
84
|
+
// Only skip steal when the pid is alive, recorded starttime is non-zero,
|
|
85
|
+
// and it matches /proc field 22.
|
|
86
|
+
if (!starttime) return false;
|
|
87
|
+
const live = readStarttime(pid);
|
|
88
|
+
if (!live) return false;
|
|
89
|
+
return live === starttime;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseJsonl(text, filePath) {
|
|
93
|
+
const messages = [];
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
96
|
+
const line = lines[i];
|
|
97
|
+
if (!line || line.trim() === "") continue;
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(line);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`hq-lanes-drain: skipping unparseable ${filePath} line ${i + 1}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
`hq-lanes-drain: skipping non-object ${filePath} line ${i + 1}\n`,
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const textField = parsed.text;
|
|
114
|
+
messages.push({
|
|
115
|
+
...parsed,
|
|
116
|
+
text: typeof textField === "string" ? textField : JSON.stringify(parsed),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return messages;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readClaimed(filePath) {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
process.stderr.write(
|
|
128
|
+
`hq-lanes-drain: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
return parseJsonl(text, filePath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Liveness first (watcher.ts reads logs/tool-activity.json). A Claude worker
|
|
136
|
+
// writes no output until it exits, so this touch on every tool call is the
|
|
137
|
+
// only progress signal it produces. Recorded before the drain so a failed
|
|
138
|
+
// drain still leaves proof the worker called a tool. Never fatal.
|
|
139
|
+
try {
|
|
140
|
+
const activityDir = path.join(hqRoot, "workspace", "lanes-runs", laneId, "logs");
|
|
141
|
+
fs.mkdirSync(activityDir, { recursive: true });
|
|
142
|
+
const activityDest = path.join(activityDir, "tool-activity.json");
|
|
143
|
+
const activityTmp = `${activityDest}.${process.pid}.${unique()}.tmp`;
|
|
144
|
+
fs.writeFileSync(
|
|
145
|
+
activityTmp,
|
|
146
|
+
`${JSON.stringify({ at: new Date().toISOString(), hook_pid: process.pid, hook_event: "PreToolUse" })}\n`,
|
|
147
|
+
);
|
|
148
|
+
fs.renameSync(activityTmp, activityDest);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const box = path.join(hqRoot, "workspace", "lanes-runs", laneId, "dropbox");
|
|
156
|
+
const inbox = path.join(box, "inbox.jsonl");
|
|
157
|
+
const draining = path.join(box, "draining");
|
|
158
|
+
const delivered = path.join(box, "delivered");
|
|
159
|
+
const inflightRoot = path.join(box, "inflight");
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(box, { recursive: true });
|
|
163
|
+
fs.mkdirSync(draining, { recursive: true });
|
|
164
|
+
fs.mkdirSync(delivered, { recursive: true });
|
|
165
|
+
fs.mkdirSync(inflightRoot, { recursive: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`hq-lanes-drain: failed to open drop box ${box}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
169
|
+
);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const starttime = readStarttime(process.pid);
|
|
174
|
+
const inflightDir = path.join(inflightRoot, `${process.pid}.${starttime}.${unique()}`);
|
|
175
|
+
fs.mkdirSync(inflightDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
if (fs.existsSync(inbox)) {
|
|
178
|
+
tryRename(inbox, path.join(inflightDir, "inbox.jsonl"));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const name of listNames(draining)) {
|
|
182
|
+
tryRename(path.join(draining, name), path.join(inflightDir, name));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const name of listNames(inflightRoot)) {
|
|
186
|
+
const other = path.join(inflightRoot, name);
|
|
187
|
+
if (other === inflightDir) continue;
|
|
188
|
+
const parts = name.split(".");
|
|
189
|
+
const pidPart = Number(parts[0]);
|
|
190
|
+
const startPart = Number(parts[1]);
|
|
191
|
+
if (
|
|
192
|
+
Number.isInteger(pidPart) &&
|
|
193
|
+
pidPart !== process.pid &&
|
|
194
|
+
pidAlive(pidPart, Number.isInteger(startPart) ? startPart : 0)
|
|
195
|
+
) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
for (const file of listNames(other)) {
|
|
199
|
+
tryRename(path.join(other, file), path.join(inflightDir, file));
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.rmdirSync(other);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err && err.code !== "ENOENT" && err.code !== "ENOTEMPTY") throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const claimed = listNames(inflightDir);
|
|
209
|
+
const messages = [];
|
|
210
|
+
for (const name of claimed) {
|
|
211
|
+
messages.push(...readClaimed(path.join(inflightDir, name)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const name of claimed) {
|
|
215
|
+
const src = path.join(inflightDir, name);
|
|
216
|
+
const dest = path.join(delivered, `${unique()}.${name}`);
|
|
217
|
+
if (!tryRename(src, dest)) {
|
|
218
|
+
process.stderr.write(`hq-lanes-drain: claimed file disappeared: ${src}\n`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
fs.rmdirSync(inflightDir);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err && err.code !== "ENOENT" && err.code !== "ENOTEMPTY") throw err;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (messages.length === 0) process.exit(0);
|
|
229
|
+
|
|
230
|
+
const body = messages
|
|
231
|
+
.map((msg, i) => {
|
|
232
|
+
const header =
|
|
233
|
+
msg.kind === "question_answer"
|
|
234
|
+
? `${i + 1}. Answer to question ${msg.question_id ?? "?"} — choice: ${msg.choice ?? "?"}`
|
|
235
|
+
: `${i + 1}.`;
|
|
236
|
+
return `${header}\n${msg.text}`;
|
|
237
|
+
})
|
|
238
|
+
.join("\n\n");
|
|
239
|
+
const additionalContext = [
|
|
240
|
+
"OPERATOR INSTRUCTION (outranks the lane brief).",
|
|
241
|
+
"The following messages were delivered through this lane's drop box. Treat them as operator instructions that outrank the brief.",
|
|
242
|
+
"",
|
|
243
|
+
body,
|
|
244
|
+
].join("\n");
|
|
245
|
+
|
|
246
|
+
process.stdout.write(
|
|
247
|
+
`${JSON.stringify({
|
|
248
|
+
hookSpecificOutput: {
|
|
249
|
+
hookEventName: "PreToolUse",
|
|
250
|
+
additionalContext,
|
|
251
|
+
},
|
|
252
|
+
})}\n`,
|
|
253
|
+
);
|
|
254
|
+
process.exit(0);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hq-lanes-drain.sh — PreToolUse drain for a running `hq lanes` worker (spec §6.5).
|
|
3
|
+
#
|
|
4
|
+
# Registered once under $GROK_HOME/hooks/. Resolves the lane from HQ_LANE_ID.
|
|
5
|
+
# The no-op path is the most important behaviour: this hook runs on every grok
|
|
6
|
+
# session on this box. If HQ_LANE_ID is absent or empty, exit 0 with no output.
|
|
7
|
+
set -u
|
|
8
|
+
|
|
9
|
+
if [ -z "${HQ_LANE_ID:-}" ]; then
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
set -eo pipefail
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
|
+
MJS="$SCRIPT_DIR/hq-lanes-drain.mjs"
|
|
17
|
+
if [ ! -f "$MJS" ]; then
|
|
18
|
+
printf 'hq-lanes-drain: missing %s\n' "$MJS" >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
exec node "$MJS"
|