@indigoai-us/hq-cli 5.345.178 → 5.345.180
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 +39 -0
- package/dist/command-catalog.generated.d.ts +197 -1
- package/dist/command-catalog.generated.js +241 -1
- package/dist/commands/bot-jobs.d.ts +11 -9
- package/dist/commands/bot-jobs.js +56 -21
- package/dist/commands/bot.d.ts +37 -1
- package/dist/commands/bot.js +289 -13
- package/dist/commands/channels.d.ts +18 -1
- package/dist/commands/channels.js +185 -1
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +15 -4
- package/dist/commands/integrations-core.d.ts +2 -0
- package/dist/commands/integrations-core.js +1 -0
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +6 -2
- package/dist/commands/mesh.js +19 -0
- package/dist/commands/sources.d.ts +89 -0
- package/dist/commands/sources.js +363 -1
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2285 -1743
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/config.d.ts +8 -0
- package/dist/lib/bot/fleet-local.d.ts +1 -1
- package/dist/lib/bot/fleet-local.js +82 -30
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
- package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
- package/dist/lib/bot/local-jobs.d.ts +40 -4
- package/dist/lib/bot/local-jobs.js +72 -5
- package/dist/lib/bot/run.d.ts +15 -0
- package/dist/lib/bot/run.js +99 -1
- package/dist/lib/bot/runtime-auth.d.ts +119 -0
- package/dist/lib/bot/runtime-auth.js +251 -0
- package/dist/lib/daemon/catalog.d.ts +1 -0
- package/dist/lib/daemon/catalog.js +1 -1
- package/dist/lib/daemon/handoff.d.ts +4 -0
- package/dist/lib/daemon/handoff.js +6 -2
- package/dist/lib/daemon/install.d.ts +70 -2
- package/dist/lib/daemon/install.js +185 -27
- package/dist/lib/daemon/legacy.d.ts +9 -3
- package/dist/lib/daemon/legacy.js +11 -4
- package/dist/lib/daemon/migrate.js +1 -1
- package/dist/lib/daemon/run.d.ts +5 -2
- package/dist/lib/daemon/run.js +74 -22
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
- package/dist/lib/doctor/checks/anywhere.js +35 -6
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
- package/dist/lib/hot-channels/adapters/attio.js +374 -0
- package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
- package/dist/lib/hot-channels/adapters/gmail.js +413 -0
- package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
- package/dist/lib/hot-channels/adapters/index.js +39 -0
- package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
- package/dist/lib/hot-channels/adapters/linear.js +321 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
- package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
- package/dist/lib/hot-channels/adapters/meetings.js +525 -0
- package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
- package/dist/lib/hot-channels/adapters/slack.js +277 -0
- package/dist/lib/hot-channels/alert.d.ts +78 -0
- package/dist/lib/hot-channels/alert.js +179 -0
- package/dist/lib/hot-channels/audience.d.ts +15 -0
- package/dist/lib/hot-channels/audience.js +25 -0
- package/dist/lib/hot-channels/contract.d.ts +146 -0
- package/dist/lib/hot-channels/contract.js +20 -0
- package/dist/lib/hot-channels/cursors.d.ts +41 -0
- package/dist/lib/hot-channels/cursors.js +84 -0
- package/dist/lib/hot-channels/index.d.ts +10 -0
- package/dist/lib/hot-channels/index.js +10 -0
- package/dist/lib/hot-channels/landing.d.ts +83 -0
- package/dist/lib/hot-channels/landing.js +175 -0
- package/dist/lib/hot-channels/ledger.d.ts +101 -0
- package/dist/lib/hot-channels/ledger.js +203 -0
- package/dist/lib/hot-channels/loop.d.ts +74 -0
- package/dist/lib/hot-channels/loop.js +221 -0
- package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
- package/dist/lib/hot-channels/registry.d.ts +28 -0
- package/dist/lib/hot-channels/registry.js +121 -0
- package/dist/lib/hot-channels/run.d.ts +137 -0
- package/dist/lib/hot-channels/run.js +324 -0
- package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
- package/dist/lib/hot-channels/signals-pass.js +581 -0
- package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
- package/dist/lib/hot-channels/sync-lock.js +224 -0
- package/dist/lib/install/hook-target.d.ts +6 -2
- package/dist/lib/install/hook-target.js +9 -4
- package/dist/lib/lanes/accounts.d.ts +1 -17
- package/dist/lib/lanes/accounts.js +5 -45
- package/dist/lib/lanes/loop.d.ts +10 -0
- package/dist/lib/lanes/loop.js +18 -1
- package/dist/lib/lanes/provider-home.d.ts +24 -0
- package/dist/lib/lanes/provider-home.js +52 -0
- package/dist/lib/lanes/spawn.d.ts +12 -0
- package/dist/lib/lanes/spawn.js +27 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +13 -0
- package/dist/lib/mesh/live/daemon/owner-inbox-push.d.ts +94 -0
- package/dist/lib/mesh/live/daemon/owner-inbox-push.js +273 -0
- package/dist/lib/mesh/live/daemon/run.js +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +14 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +210 -3
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +2 -0
- package/dist/lib/sync-core/flag.d.ts +1 -1
- package/dist/lib/sync-core/flag.js +1 -1
- package/dist/lib/sync-core/reconcile.d.ts +1 -1
- package/dist/lib/sync-core/reconcile.js +9 -7
- package/dist/lib/sync-core/release.d.ts +3 -3
- package/dist/lib/sync-core/release.js +4 -4
- package/dist/lib/sync-core/rollout-state.d.ts +1 -0
- package/dist/lib/sync-core/rollout-state.js +1 -0
- package/package.json +8 -8
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One hot-channel sync at a time per person per company. The bot's scheduled
|
|
3
|
+
* run and a manual `hq sources sync` can fire together; the index files are
|
|
4
|
+
* read-modify-write, so two runs at once can lose rows and process the same
|
|
5
|
+
* event twice. The lock is `sources/_runs/@{personKey}/.lock`, created with
|
|
6
|
+
* O_EXCL and holding the pid, host, start time and last heartbeat.
|
|
7
|
+
*
|
|
8
|
+
* A lock is stale when its holder on this host is no longer alive, or when its
|
|
9
|
+
* last heartbeat is older than `SYNC_LOCK_STALE_MS`. The holder heartbeats the
|
|
10
|
+
* lock while it runs, so a long signals pass is never taken over mid-write.
|
|
11
|
+
* Takeover runs under an O_EXCL `.takeover` guard: the guard holder renames the
|
|
12
|
+
* stale file to a unique name, checks it moved the file it judged stale, and
|
|
13
|
+
* every contender then competes on the O_EXCL create of a fresh lock.
|
|
14
|
+
* Release removes the file only while it still names this holder.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
|
|
21
|
+
/** A lock whose last heartbeat is older than this is stale. */
|
|
22
|
+
export const SYNC_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
23
|
+
/** How often a held lock is refreshed; well under the stale limit. */
|
|
24
|
+
export const SYNC_LOCK_HEARTBEAT_MS = 60 * 1000;
|
|
25
|
+
export const SYNC_LOCK_BUSY_REASON = "another sync is running";
|
|
26
|
+
/**
|
|
27
|
+
* The run no longer holds its sync lock (another run took it over, for example
|
|
28
|
+
* after this machine slept past the stale limit). The run stops before its
|
|
29
|
+
* next write.
|
|
30
|
+
*/
|
|
31
|
+
export class SyncLockLostError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("sync lock lost to another run; stopped before the next write");
|
|
34
|
+
this.name = "SyncLockLostError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function syncLockPath(vaultRoot, personKey) {
|
|
38
|
+
const file = path.join(vaultRoot, "sources", "_runs", personDirName(personKey), ".lock");
|
|
39
|
+
assertNotIntegrationSecretsPath(file);
|
|
40
|
+
return file;
|
|
41
|
+
}
|
|
42
|
+
function serialize(body) {
|
|
43
|
+
return `${JSON.stringify(body)}\n`;
|
|
44
|
+
}
|
|
45
|
+
async function tryCreate(file, body) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.writeFile(file, serialize(body), { encoding: "utf8", flag: "wx" });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "EEXIST")
|
|
52
|
+
return false;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function readBody(file) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
59
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err.code === "ENOENT")
|
|
63
|
+
return "missing";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True when `pid` names a live process on this host. EPERM means it exists. */
|
|
68
|
+
export function pidAlive(pid) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return err.code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function isStale(file, nowMs, staleMs, alive) {
|
|
78
|
+
const body = await readBody(file);
|
|
79
|
+
if (body === "missing")
|
|
80
|
+
return "missing";
|
|
81
|
+
if (body && typeof body.pid === "number" && body.host === os.hostname() && !alive(body.pid))
|
|
82
|
+
return true;
|
|
83
|
+
const stamp = body?.heartbeatAt ?? body?.startedAt;
|
|
84
|
+
let at = typeof stamp === "string" ? Date.parse(stamp) : Number.NaN;
|
|
85
|
+
if (Number.isNaN(at)) {
|
|
86
|
+
try {
|
|
87
|
+
at = (await fs.stat(file)).mtimeMs;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return "missing";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return nowMs - at >= staleMs;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Take the sync lock, or return null when a live run holds it. A stale lock is
|
|
97
|
+
* moved aside and taken over once; a race lost during takeover returns null.
|
|
98
|
+
*/
|
|
99
|
+
export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
|
|
100
|
+
const now = options.now ?? (() => new Date());
|
|
101
|
+
const staleMs = options.staleMs ?? SYNC_LOCK_STALE_MS;
|
|
102
|
+
const heartbeatMs = options.heartbeatMs ?? SYNC_LOCK_HEARTBEAT_MS;
|
|
103
|
+
const alive = options.pidAlive ?? pidAlive;
|
|
104
|
+
const file = syncLockPath(vaultRoot, personKey);
|
|
105
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
106
|
+
const startedAt = now().toISOString();
|
|
107
|
+
const body = { pid: process.pid, host: os.hostname(), startedAt, heartbeatAt: startedAt };
|
|
108
|
+
const ownsFile = async () => {
|
|
109
|
+
const current = await readBody(file);
|
|
110
|
+
return (current !== null &&
|
|
111
|
+
current !== "missing" &&
|
|
112
|
+
current.pid === body.pid &&
|
|
113
|
+
current.host === body.host &&
|
|
114
|
+
current.startedAt === body.startedAt);
|
|
115
|
+
};
|
|
116
|
+
const held = () => {
|
|
117
|
+
let released = false;
|
|
118
|
+
let timer;
|
|
119
|
+
const heartbeat = async () => {
|
|
120
|
+
if (released || !(await ownsFile()))
|
|
121
|
+
return;
|
|
122
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
123
|
+
await fs.writeFile(tmp, serialize({ ...body, heartbeatAt: now().toISOString() }), "utf8");
|
|
124
|
+
await fs.rename(tmp, file);
|
|
125
|
+
};
|
|
126
|
+
if (heartbeatMs > 0) {
|
|
127
|
+
timer = setInterval(() => {
|
|
128
|
+
heartbeat().catch(() => undefined);
|
|
129
|
+
}, heartbeatMs);
|
|
130
|
+
timer.unref();
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
file,
|
|
134
|
+
assertHeld: async () => {
|
|
135
|
+
if (released || !(await ownsFile()))
|
|
136
|
+
throw new SyncLockLostError();
|
|
137
|
+
},
|
|
138
|
+
heartbeat,
|
|
139
|
+
release: async () => {
|
|
140
|
+
released = true;
|
|
141
|
+
if (timer)
|
|
142
|
+
clearInterval(timer);
|
|
143
|
+
if (await ownsFile())
|
|
144
|
+
await fs.rm(file, { force: true });
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
if (await tryCreate(file, body))
|
|
149
|
+
return held();
|
|
150
|
+
const stale = await isStale(file, now().getTime(), staleMs, alive);
|
|
151
|
+
if (stale === false)
|
|
152
|
+
return null;
|
|
153
|
+
if (stale === true && !(await takeOverStale(file, now, staleMs, alive)))
|
|
154
|
+
return null;
|
|
155
|
+
return (await tryCreate(file, body)) ? held() : null;
|
|
156
|
+
}
|
|
157
|
+
/** A takeover guard older than this belongs to a contender that died mid-takeover. */
|
|
158
|
+
const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
|
|
159
|
+
/**
|
|
160
|
+
* Move a stale lock out of the way. Only the contender that O_EXCL-creates the
|
|
161
|
+
* `.takeover` guard may touch the lock file, so two contenders can never both
|
|
162
|
+
* remove it. Under the guard the lock is re-read, renamed to a unique name,
|
|
163
|
+
* and the moved file is compared with what was read: if it changed in between
|
|
164
|
+
* (its holder released and a new run took the lock), it is put back. Returns
|
|
165
|
+
* false when this contender should give up.
|
|
166
|
+
*/
|
|
167
|
+
async function takeOverStale(file, now, staleMs, alive) {
|
|
168
|
+
const guard = `${file}.takeover`;
|
|
169
|
+
const takeGuard = async () => {
|
|
170
|
+
try {
|
|
171
|
+
await fs.writeFile(guard, `${process.pid}\n`, { encoding: "utf8", flag: "wx" });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err.code !== "EEXIST")
|
|
176
|
+
throw err;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if (!(await takeGuard())) {
|
|
181
|
+
let age = 0;
|
|
182
|
+
try {
|
|
183
|
+
age = now().getTime() - (await fs.stat(guard)).mtimeMs;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (age < TAKEOVER_GUARD_STALE_MS)
|
|
189
|
+
return false;
|
|
190
|
+
const staleGuard = `${guard}.stale-${randomUUID()}`;
|
|
191
|
+
await fs.rename(guard, staleGuard).catch(() => undefined);
|
|
192
|
+
await fs.rm(staleGuard, { force: true });
|
|
193
|
+
if (!(await takeGuard()))
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const seen = await fs.readFile(file, "utf8").catch(() => null);
|
|
198
|
+
if (seen === null)
|
|
199
|
+
return true;
|
|
200
|
+
if ((await isStale(file, now().getTime(), staleMs, alive)) !== true)
|
|
201
|
+
return false;
|
|
202
|
+
const aside = `${file}.stale-${process.pid}-${randomUUID()}`;
|
|
203
|
+
try {
|
|
204
|
+
await fs.rename(file, aside);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err.code === "ENOENT")
|
|
208
|
+
return true;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
const moved = await fs.readFile(aside, "utf8").catch(() => null);
|
|
212
|
+
if (moved !== seen) {
|
|
213
|
+
await fs.link(aside, file).catch(() => undefined);
|
|
214
|
+
await fs.rm(aside, { force: true });
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
await fs.rm(aside, { force: true });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await fs.rm(guard, { force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=sync-lock.js.map
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type HookTarget } from './claude-targets.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* True when this user's hq daemon unit (launchd plist, systemd unit, or the
|
|
4
|
+
* per-user Windows Scheduled Task) exists. On Windows the shared task an older
|
|
5
|
+
* install registered counts only when it runs as this user.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isDaemonInstalled(home: string, platform?: NodeJS.Platform, queryTask?: (task: string) => boolean, taskName?: () => string, legacyTaskIsMine?: () => boolean): boolean;
|
|
4
8
|
/**
|
|
5
9
|
* US-010: with hqd installed (and the checkout shipping the shim), hooks go to
|
|
6
10
|
* the hqd shim; otherwise to master-hook.sh / the Codex adapter.
|
|
@@ -7,15 +7,20 @@ import * as fs from 'node:fs';
|
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { realpathOrSelf } from '../../commands/mcp-registration.js';
|
|
10
|
-
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT,
|
|
10
|
+
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, SYSTEM_UNIT_DIR, currentWindowsIdentity, legacyWindowsTask, windowsTaskName } from '../daemon/install.js';
|
|
11
|
+
import { defaultRunCommand } from '../service-manager/launchd.js';
|
|
11
12
|
import { readDaemonConfig } from '../daemon/config.js';
|
|
12
13
|
import { daemonPaths } from '../daemon/paths.js';
|
|
13
14
|
import { registrationEngine } from '../hook-engine/registration.js';
|
|
14
15
|
import { shimPath } from './claude-targets.js';
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* True when this user's hq daemon unit (launchd plist, systemd unit, or the
|
|
18
|
+
* per-user Windows Scheduled Task) exists. On Windows the shared task an older
|
|
19
|
+
* install registered counts only when it runs as this user.
|
|
20
|
+
*/
|
|
21
|
+
export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0, taskName = () => windowsTaskName(currentWindowsIdentity()), legacyTaskIsMine = () => legacyWindowsTask(defaultRunCommand, currentWindowsIdentity()).state === 'mine') {
|
|
17
22
|
if (platform === 'win32')
|
|
18
|
-
return queryTask(
|
|
23
|
+
return queryTask(taskName()) || legacyTaskIsMine();
|
|
19
24
|
const candidates = platform === 'darwin'
|
|
20
25
|
? [path.join(home, 'Library', 'LaunchAgents', `${DAEMON_LAUNCHD_LABEL}.plist`)]
|
|
21
26
|
: platform === 'linux'
|
|
@@ -13,23 +13,7 @@
|
|
|
13
13
|
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
14
14
|
import type { LanesConfig } from "./types.js";
|
|
15
15
|
import type { ClaudeCredentialReader, ClaudeTokenState } from "./claude-credential.js";
|
|
16
|
-
export
|
|
17
|
-
/**
|
|
18
|
-
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
19
|
-
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
20
|
-
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
21
|
-
* for that path. Setting the variable for the default home therefore makes a
|
|
22
|
-
* lane use a different login than the interactive session (incident
|
|
23
|
-
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
24
|
-
* the variable unset instead.
|
|
25
|
-
*/
|
|
26
|
-
export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
|
|
27
|
-
/**
|
|
28
|
-
* Return a copy of `base` with the provider home variable set to `home`,
|
|
29
|
-
* or, for a Claude account on the default home, with the variable removed
|
|
30
|
-
* so the worker and the interactive session share one credential.
|
|
31
|
-
*/
|
|
32
|
-
export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
|
|
16
|
+
export { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
|
|
33
17
|
/** Who `claude auth status` says a home is logged in as. Never a token. */
|
|
34
18
|
export interface ClaudeIdentity {
|
|
35
19
|
email: string | null;
|
|
@@ -11,57 +11,17 @@
|
|
|
11
11
|
* This module never prints, copies, or hashes credentials.
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
|
-
import * as os from "node:os";
|
|
15
14
|
import { effectiveAccountHome } from "./account-home.js";
|
|
16
15
|
import * as path from "node:path";
|
|
17
16
|
import { AccountResolutionError } from "./errors.js";
|
|
18
17
|
import { isTerminalLaneState } from "./types.js";
|
|
19
|
-
import {
|
|
18
|
+
import { accountMaxRunning } from "./config.js";
|
|
20
19
|
import { listLaneIds, readLane } from "./store.js";
|
|
21
20
|
import { loadQuotaState, quotaBlockFor } from "./quota.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
29
|
-
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
30
|
-
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
31
|
-
* for that path. Setting the variable for the default home therefore makes a
|
|
32
|
-
* lane use a different login than the interactive session (incident
|
|
33
|
-
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
34
|
-
* the variable unset instead.
|
|
35
|
-
*/
|
|
36
|
-
export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
|
|
37
|
-
const deps = {
|
|
38
|
-
homedir,
|
|
39
|
-
realpathSync: (p) => fs.realpathSync(p),
|
|
40
|
-
existsSync: (p) => fs.existsSync(p),
|
|
41
|
-
};
|
|
42
|
-
try {
|
|
43
|
-
return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Return a copy of `base` with the provider home variable set to `home`,
|
|
51
|
-
* or, for a Claude account on the default home, with the variable removed
|
|
52
|
-
* so the worker and the interactive session share one credential.
|
|
53
|
-
*/
|
|
54
|
-
export function withProviderHomeEnv(base, provider, home, homedir) {
|
|
55
|
-
const next = { ...base };
|
|
56
|
-
const envVar = PROVIDER_HOME_ENV[provider];
|
|
57
|
-
if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
|
|
58
|
-
delete next[envVar];
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
next[envVar] = home;
|
|
62
|
-
}
|
|
63
|
-
return next;
|
|
64
|
-
}
|
|
21
|
+
// Provider home helpers live in a leaf module so bot code can use them
|
|
22
|
+
// without importing the lane store; re-exported for existing callers.
|
|
23
|
+
export { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
|
|
24
|
+
import { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
|
|
65
25
|
function optionalString(value) {
|
|
66
26
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
67
27
|
}
|
package/dist/lib/lanes/loop.d.ts
CHANGED
|
@@ -222,6 +222,16 @@ export declare function inspectLoopRecovery(runDir: string): {
|
|
|
222
222
|
kind: "pending" | "hold" | "protected" | "empty";
|
|
223
223
|
reason?: string;
|
|
224
224
|
};
|
|
225
|
+
/**
|
|
226
|
+
* Budget for an envelope that carries an absolute deadline. The deadline is
|
|
227
|
+
* stamped when the phase is routed, so the budget is the time between enqueue
|
|
228
|
+
* and that deadline, counted from when the lane starts the envelope. Time the
|
|
229
|
+
* envelope spent waiting in the queue does not count against it. Without a
|
|
230
|
+
* usable queued_at the remaining time from start is used, as before.
|
|
231
|
+
* A deadline at or before its enqueue still gets a 1 s execution window.
|
|
232
|
+
* An unparsable deadline returns undefined so the caller uses the phase budget.
|
|
233
|
+
*/
|
|
234
|
+
export declare function absoluteDeadlineBudget(deadline: string, queuedAt: string | undefined, startedAtMs: number): number | undefined;
|
|
225
235
|
/** Reset only after an explicit, durable disposition; this never starts work. */
|
|
226
236
|
export declare function resetLoopAfterDisposition(runDir: string, laneId: string, decision: string, hasPending: boolean): void;
|
|
227
237
|
/** Run queued phases serially. Sessions are isolated by story and provider. */
|
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -985,6 +985,23 @@ function phaseDeadlineBudget(envelope, defaultSeconds) {
|
|
|
985
985
|
const configured = Number(process.env.HQ_LANE_LOOP_PHASE_DEADLINE_SECS);
|
|
986
986
|
return Number.isFinite(configured) && configured > 0 ? configured : defaultSeconds;
|
|
987
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Budget for an envelope that carries an absolute deadline. The deadline is
|
|
990
|
+
* stamped when the phase is routed, so the budget is the time between enqueue
|
|
991
|
+
* and that deadline, counted from when the lane starts the envelope. Time the
|
|
992
|
+
* envelope spent waiting in the queue does not count against it. Without a
|
|
993
|
+
* usable queued_at the remaining time from start is used, as before.
|
|
994
|
+
* A deadline at or before its enqueue still gets a 1 s execution window.
|
|
995
|
+
* An unparsable deadline returns undefined so the caller uses the phase budget.
|
|
996
|
+
*/
|
|
997
|
+
export function absoluteDeadlineBudget(deadline, queuedAt, startedAtMs) {
|
|
998
|
+
const deadlineMs = Date.parse(deadline);
|
|
999
|
+
if (!Number.isFinite(deadlineMs))
|
|
1000
|
+
return undefined;
|
|
1001
|
+
const queuedMs = typeof queuedAt === "string" ? Date.parse(queuedAt) : Number.NaN;
|
|
1002
|
+
const fromMs = Number.isFinite(queuedMs) && queuedMs <= startedAtMs ? queuedMs : startedAtMs;
|
|
1003
|
+
return Math.max(1, Math.round((deadlineMs - fromMs) / 1000));
|
|
1004
|
+
}
|
|
988
1005
|
function persistStallCount(runDir, laneId, envelopeId, count) {
|
|
989
1006
|
const status = loopStatus(runDir, laneId);
|
|
990
1007
|
persistStatus(runDir, laneId, { stall_counts: { ...status.stall_counts, [envelopeId]: count } });
|
|
@@ -1121,7 +1138,7 @@ export async function runLoopLane(options) {
|
|
|
1121
1138
|
const absoluteDeadline = envelope.deadline;
|
|
1122
1139
|
const budget = record.deadline_budget_seconds ?? firstDeadline.get(record.envelope_id) ??
|
|
1123
1140
|
(typeof absoluteDeadline === "string"
|
|
1124
|
-
?
|
|
1141
|
+
? absoluteDeadlineBudget(absoluteDeadline, record.queued_at, startedAtMs) ?? phaseDeadlineBudget(envelope, defaultDeadlineSeconds)
|
|
1125
1142
|
: phaseDeadlineBudget(envelope, defaultDeadlineSeconds));
|
|
1126
1143
|
firstDeadline.set(record.envelope_id, budget);
|
|
1127
1144
|
record.deadline_budget_seconds = budget;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider home environment helpers, split from accounts.ts so callers that
|
|
3
|
+
* only need to point a provider at a home (bots) do not pull in the lane
|
|
4
|
+
* store and its daemon imports. accounts.ts re-exports these.
|
|
5
|
+
*/
|
|
6
|
+
import type { LaneProvider } from "./types.js";
|
|
7
|
+
export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
10
|
+
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
11
|
+
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
12
|
+
* for that path. Setting the variable for the default home therefore makes a
|
|
13
|
+
* lane use a different login than the interactive session (incident
|
|
14
|
+
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
15
|
+
* the variable unset instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Return a copy of `base` with the provider home variable set to `home`,
|
|
20
|
+
* or, for a Claude account on the default home, with the variable removed
|
|
21
|
+
* so the worker and the interactive session share one credential.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
|
|
24
|
+
//# sourceMappingURL=provider-home.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider home environment helpers, split from accounts.ts so callers that
|
|
3
|
+
* only need to point a provider at a home (bots) do not pull in the lane
|
|
4
|
+
* store and its daemon imports. accounts.ts re-exports these.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import { DEFAULT_ACCOUNT_HOMES, canonicalizeHome } from "./config.js";
|
|
9
|
+
export const PROVIDER_HOME_ENV = {
|
|
10
|
+
claude: "CLAUDE_CONFIG_DIR",
|
|
11
|
+
codex: "CODEX_HOME",
|
|
12
|
+
grok: "GROK_HOME",
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
16
|
+
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
17
|
+
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
18
|
+
* for that path. Setting the variable for the default home therefore makes a
|
|
19
|
+
* lane use a different login than the interactive session (incident
|
|
20
|
+
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
21
|
+
* the variable unset instead.
|
|
22
|
+
*/
|
|
23
|
+
export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
|
|
24
|
+
const deps = {
|
|
25
|
+
homedir,
|
|
26
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
27
|
+
existsSync: (p) => fs.existsSync(p),
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return a copy of `base` with the provider home variable set to `home`,
|
|
38
|
+
* or, for a Claude account on the default home, with the variable removed
|
|
39
|
+
* so the worker and the interactive session share one credential.
|
|
40
|
+
*/
|
|
41
|
+
export function withProviderHomeEnv(base, provider, home, homedir) {
|
|
42
|
+
const next = { ...base };
|
|
43
|
+
const envVar = PROVIDER_HOME_ENV[provider];
|
|
44
|
+
if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
|
|
45
|
+
delete next[envVar];
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
next[envVar] = home;
|
|
49
|
+
}
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=provider-home.js.map
|
|
@@ -204,6 +204,18 @@ export declare const FOREGROUND_ONLY_RULE: string;
|
|
|
204
204
|
* install has it, the command fails with "No such file".
|
|
205
205
|
*/
|
|
206
206
|
export declare function tempHomeRule(hqRoot: string): string;
|
|
207
|
+
/**
|
|
208
|
+
* Self-review rule carried next to the temp-home rule in every composed
|
|
209
|
+
* brief, for every provider. A PR URL reaches the core-pr-reviews channel only
|
|
210
|
+
* after two zero-critical reviews of its head: the lane's own review and an
|
|
211
|
+
* independent senior review. The lane supplies the first one and never posts.
|
|
212
|
+
*
|
|
213
|
+
* The report file under workspace/reviews/ uses the format the hq-core
|
|
214
|
+
* channel guard (block-core-pr-reviews-dm.sh) reads before a URL is posted.
|
|
215
|
+
* The skill path is absolute so codex and grok lanes, which have no /review
|
|
216
|
+
* slash command, can read and follow it.
|
|
217
|
+
*/
|
|
218
|
+
export declare function selfReviewRule(hqRoot: string): string;
|
|
207
219
|
/**
|
|
208
220
|
* The answers as written by the reconcile pass (`answered-questions.json`),
|
|
209
221
|
* reduced to id + choice. A malformed file reads as no inline answers; the
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1378,6 +1378,33 @@ export function tempHomeRule(hqRoot) {
|
|
|
1378
1378
|
"each fresh home re-downloads the Rust toolchain and pnpm store and is never deleted. " +
|
|
1379
1379
|
"The wrapper keeps the real caches and removes the temp home when the command exits.");
|
|
1380
1380
|
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Self-review rule carried next to the temp-home rule in every composed
|
|
1383
|
+
* brief, for every provider. A PR URL reaches the core-pr-reviews channel only
|
|
1384
|
+
* after two zero-critical reviews of its head: the lane's own review and an
|
|
1385
|
+
* independent senior review. The lane supplies the first one and never posts.
|
|
1386
|
+
*
|
|
1387
|
+
* The report file under workspace/reviews/ uses the format the hq-core
|
|
1388
|
+
* channel guard (block-core-pr-reviews-dm.sh) reads before a URL is posted.
|
|
1389
|
+
* The skill path is absolute so codex and grok lanes, which have no /review
|
|
1390
|
+
* slash command, can read and follow it.
|
|
1391
|
+
*/
|
|
1392
|
+
export function selfReviewRule(hqRoot) {
|
|
1393
|
+
const root = path.resolve(hqRoot);
|
|
1394
|
+
const skill = path.join(root, ".claude", "skills", "review", "SKILL.md");
|
|
1395
|
+
const reviews = path.join(root, "workspace", "reviews");
|
|
1396
|
+
return ("SELF-REVIEW. Before reporting done on work that opens or updates a PR, " +
|
|
1397
|
+
`run the paranoid /review (${skill}) on your own final diff, fix every critical, ` +
|
|
1398
|
+
"and re-run until there are 0 criticals. " +
|
|
1399
|
+
"Put the report in the PR body under a 'Self-review' heading with the head SHA. " +
|
|
1400
|
+
`Also save it as a file under ${reviews}/ whose first lines are ` +
|
|
1401
|
+
"`Review-Report: hq-pr-review.v1`, `PR: <url>`, `Head: <40-char head SHA>`, " +
|
|
1402
|
+
"`Reviewer-Role: self-review` and `Criticals: 0`. " +
|
|
1403
|
+
"Save only the final zero-critical run in that file: no other line may start with " +
|
|
1404
|
+
"`PR:`, `Head:`, `Reviewer-Role:` or `Criticals:`. " +
|
|
1405
|
+
"A new commit needs a new review of the new head. " +
|
|
1406
|
+
"Never post to core-pr-reviews. If you cannot produce the report, mark the PR as draft.");
|
|
1407
|
+
}
|
|
1381
1408
|
/**
|
|
1382
1409
|
* The answers as written by the reconcile pass (`answered-questions.json`),
|
|
1383
1410
|
* reduced to id + choice. A malformed file reads as no inline answers; the
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* outbox/last-flush, and unhealthy when unacked spool age > 60s while online.
|
|
4
4
|
*/
|
|
5
5
|
import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
|
|
6
|
+
import { type OwnerPushStatus } from "./owner-inbox-push.js";
|
|
6
7
|
import { type UnattributedEventCounts } from "../spool.js";
|
|
7
8
|
import { type DaemonStateFile } from "./state.js";
|
|
8
9
|
import { type LegacyListenPlatform } from "./legacy-listen.js";
|
|
@@ -70,6 +71,8 @@ export interface DaemonDoctorReport {
|
|
|
70
71
|
}>;
|
|
71
72
|
/** True when PATH lists more than one hq. */
|
|
72
73
|
multipleHqOnPath: boolean;
|
|
74
|
+
/** Owner DM push signal (person accounts). Content-free counter only. */
|
|
75
|
+
ownerPush?: OwnerPushStatus;
|
|
73
76
|
}
|
|
74
77
|
export interface DaemonDoctorDeps {
|
|
75
78
|
home?: string;
|
|
@@ -11,6 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
|
|
|
11
11
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
12
12
|
import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
|
|
13
13
|
import { formatDroppedPresenceCompany } from "./credentials.js";
|
|
14
|
+
import { formatOwnerPushStatus, ownerPushStatusForAccount, readOwnerInboxPushState, } from "./owner-inbox-push.js";
|
|
14
15
|
import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
15
16
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
16
17
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
@@ -248,6 +249,14 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
248
249
|
if (emitRetry > 0) {
|
|
249
250
|
unhealthyReasons.push(`direct-emit retry depth=${emitRetry}`);
|
|
250
251
|
}
|
|
252
|
+
// Only a person account has an owner DM topic; any other account reports off.
|
|
253
|
+
const ownerPush = ownerPushStatusForAccount(readOwnerInboxPushState(meshRoot), now(), auth.actorKind);
|
|
254
|
+
// Only a subscribed push whose heartbeat stopped counts. A rejected or
|
|
255
|
+
// retrying best-effort subscribe writes connected:false, and an offline
|
|
256
|
+
// daemon stops the heartbeat; neither makes the daemon unhealthy.
|
|
257
|
+
if (running && online && ownerPush.connected && ownerPush.status === "stale") {
|
|
258
|
+
unhealthyReasons.push(`owner push stale (last event ${ownerPush.lastAt ?? "never"})`);
|
|
259
|
+
}
|
|
251
260
|
return {
|
|
252
261
|
running,
|
|
253
262
|
pid: running ? record?.pid : undefined,
|
|
@@ -282,6 +291,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
282
291
|
emitMode,
|
|
283
292
|
subscribed,
|
|
284
293
|
emitRetryDepth: emitRetry,
|
|
294
|
+
ownerPush,
|
|
285
295
|
lastPostAt: emitState?.lastPostAt,
|
|
286
296
|
lastEmit: emitState
|
|
287
297
|
? {
|
|
@@ -341,6 +351,9 @@ export function formatDaemonDoctor(report) {
|
|
|
341
351
|
` unassigned=${report.lastEmit.unassigned ?? 0}` +
|
|
342
352
|
` rejected=${report.lastEmit.rejected ?? 0}`
|
|
343
353
|
: ""}`, `emit retry depth: ${report.emitRetryDepth}`);
|
|
354
|
+
if (report.ownerPush) {
|
|
355
|
+
lines.push(formatOwnerPushStatus(report.ownerPush));
|
|
356
|
+
}
|
|
344
357
|
if (report.presenceRefusal) {
|
|
345
358
|
lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
|
|
346
359
|
}
|