@indigoai-us/hq-cli 5.345.58 → 5.345.59
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 +12 -0
- package/dist/command-catalog.generated.d.ts +8 -0
- package/dist/command-catalog.generated.js +10 -0
- package/dist/commands/agent-kit.js +20 -10
- package/dist/commands/billing.d.ts +13 -0
- package/dist/commands/billing.js +31 -0
- package/dist/commands/db-dump.js +2 -2
- package/dist/commands/db-remote-deps.js +6 -0
- package/dist/commands/lanes-loop.js +3 -1
- package/dist/commands/lanes-questions.js +16 -0
- package/dist/commands/lanes-session-start.js +19 -3
- package/dist/commands/lanes-shared.d.ts +1 -0
- package/dist/commands/lanes.d.ts +3 -1
- package/dist/commands/lanes.js +52 -2
- package/dist/lib/agent-kit/inbox-poll-backoff-flag.d.ts +19 -0
- package/dist/lib/agent-kit/inbox-poll-backoff-flag.js +70 -0
- package/dist/lib/agent-kit/run/inbox.d.ts +7 -3
- package/dist/lib/agent-kit/run/inbox.js +45 -6
- package/dist/lib/agent-kit/run/stop-signals.d.ts +14 -0
- package/dist/lib/agent-kit/run/stop-signals.js +26 -0
- package/dist/lib/agent-kit/services.d.ts +2 -2
- package/dist/lib/agent-kit/services.js +5 -3
- package/dist/lib/anywhere-runtime-flag-key.d.ts +1 -0
- package/dist/lib/anywhere-runtime-flag-key.js +1 -0
- package/dist/lib/anywhere-runtime-revocation-marker.d.ts +4 -0
- package/dist/lib/anywhere-runtime-revocation-marker.js +26 -0
- package/dist/lib/daemon/run.d.ts +2 -0
- package/dist/lib/daemon/run.js +17 -4
- package/dist/lib/daemon/server.js +7 -0
- package/dist/lib/db/control-plane.d.ts +12 -1
- package/dist/lib/db/usage-format.d.ts +2 -1
- package/dist/lib/db/usage-format.js +19 -0
- package/dist/lib/install/provider-wrappers.js +13 -0
- package/dist/lib/lanes/loop.d.ts +23 -1
- package/dist/lib/lanes/loop.js +134 -19
- package/dist/lib/lanes/questions.d.ts +5 -0
- package/dist/lib/lanes/reconcile.js +3 -0
- package/dist/lib/lanes/story-ledger.js +30 -20
- package/dist/lib/lanes/types.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
export declare const KIT_STOP_GRACE_MS = 10000;
|
|
3
|
+
interface KitStopControllerOptions {
|
|
4
|
+
emitter?: Pick<EventEmitter, "once" | "removeListener">;
|
|
5
|
+
forceExit?: (code: number) => void;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Abort service work on stop, with a deadline if an awaited operation stalls. */
|
|
9
|
+
export declare function createKitStopController(options?: KitStopControllerOptions): {
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
dispose(): void;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=stop-signals.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const KIT_STOP_GRACE_MS = 10_000;
|
|
2
|
+
/** Abort service work on stop, with a deadline if an awaited operation stalls. */
|
|
3
|
+
export function createKitStopController(options = {}) {
|
|
4
|
+
const emitter = options.emitter ?? process;
|
|
5
|
+
const forceExit = options.forceExit ?? ((code) => process.exit(code));
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
let deadline;
|
|
8
|
+
const stop = () => {
|
|
9
|
+
if (controller.signal.aborted)
|
|
10
|
+
return;
|
|
11
|
+
controller.abort();
|
|
12
|
+
deadline = setTimeout(() => forceExit(0), options.timeoutMs ?? KIT_STOP_GRACE_MS);
|
|
13
|
+
};
|
|
14
|
+
emitter.once("SIGTERM", stop);
|
|
15
|
+
emitter.once("SIGINT", stop);
|
|
16
|
+
return {
|
|
17
|
+
signal: controller.signal,
|
|
18
|
+
dispose() {
|
|
19
|
+
if (deadline)
|
|
20
|
+
clearTimeout(deadline);
|
|
21
|
+
emitter.removeListener("SIGTERM", stop);
|
|
22
|
+
emitter.removeListener("SIGINT", stop);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=stop-signals.js.map
|
|
@@ -14,8 +14,8 @@ export declare function isKitService(value: string): value is KitService;
|
|
|
14
14
|
* file a loud error instead of a browser-login hang; HQ_AGENT_DIR keeps a
|
|
15
15
|
* relocated kit consistent across restarts.
|
|
16
16
|
*/
|
|
17
|
-
export declare function kitServiceEnv(paths: AgentKitPaths): Record<string, string>;
|
|
18
|
-
export declare function kitServiceSpec(service: KitService, paths: AgentKitPaths): ServiceSpec;
|
|
17
|
+
export declare function kitServiceEnv(paths: AgentKitPaths, env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
18
|
+
export declare function kitServiceSpec(service: KitService, paths: AgentKitPaths, env?: NodeJS.ProcessEnv): ServiceSpec;
|
|
19
19
|
/**
|
|
20
20
|
* Services to install. `sync` is included only when the kit is configured to
|
|
21
21
|
* mirror the vault to disk; by default a bot reads files on demand through the
|
|
@@ -20,14 +20,16 @@ const DESCRIPTIONS = {
|
|
|
20
20
|
* file a loud error instead of a browser-login hang; HQ_AGENT_DIR keeps a
|
|
21
21
|
* relocated kit consistent across restarts.
|
|
22
22
|
*/
|
|
23
|
-
export function kitServiceEnv(paths) {
|
|
23
|
+
export function kitServiceEnv(paths, env = process.env) {
|
|
24
|
+
const flagsApiUrl = env.HQ_FLAGS_API_URL;
|
|
24
25
|
return {
|
|
25
26
|
HQ_AGENT_DIR: paths.agentDir,
|
|
26
27
|
HQ_MACHINE_CREDS_FILE: paths.machineCredsPath,
|
|
27
28
|
HQ_REQUIRE_MACHINE_IDENTITY: "1",
|
|
29
|
+
...(flagsApiUrl?.trim() ? { HQ_FLAGS_API_URL: flagsApiUrl } : {}),
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
|
-
export function kitServiceSpec(service, paths) {
|
|
32
|
+
export function kitServiceSpec(service, paths, env = process.env) {
|
|
31
33
|
return {
|
|
32
34
|
name: service,
|
|
33
35
|
label: `${KIT_LABEL_PREFIX}.${service}`,
|
|
@@ -35,7 +37,7 @@ export function kitServiceSpec(service, paths) {
|
|
|
35
37
|
args: ["agent", "kit", "run", service],
|
|
36
38
|
logPath: serviceLogPath(paths, service),
|
|
37
39
|
workingDir: paths.agentDir,
|
|
38
|
-
env: kitServiceEnv(paths),
|
|
40
|
+
env: kitServiceEnv(paths, env),
|
|
39
41
|
restartSec: 10,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
|
|
2
2
|
export declare const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
|
|
3
|
+
export declare const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
|
|
3
4
|
/** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
|
|
4
5
|
export declare function hqAnywhereRuntimeLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
5
6
|
//# sourceMappingURL=anywhere-runtime-flag-key.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
|
|
2
2
|
export const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
|
|
3
|
+
export const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
|
|
3
4
|
/** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
|
|
4
5
|
export function hqAnywhereRuntimeLocallyDisabled(env = process.env) {
|
|
5
6
|
const value = env.HQ_FLAG_HQ_ANYWHERE_RUNTIME;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function anywhereRuntimeRevocationMarkerPath(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
2
|
+
/** Keep installed wrappers aligned with the daemon's refreshed access decision. */
|
|
3
|
+
export declare function updateAnywhereRuntimeRevocationMarker(enabled: boolean, home?: string, env?: NodeJS.ProcessEnv): void;
|
|
4
|
+
//# sourceMappingURL=anywhere-runtime-revocation-marker.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER } from "./anywhere-runtime-flag-key.js";
|
|
5
|
+
import { registryDir } from "./registry/registry.js";
|
|
6
|
+
export function anywhereRuntimeRevocationMarkerPath(home = os.homedir(), env = process.env) {
|
|
7
|
+
return path.join(registryDir(home, env), HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER);
|
|
8
|
+
}
|
|
9
|
+
/** Keep installed wrappers aligned with the daemon's refreshed access decision. */
|
|
10
|
+
export function updateAnywhereRuntimeRevocationMarker(enabled, home = os.homedir(), env = process.env) {
|
|
11
|
+
const marker = anywhereRuntimeRevocationMarkerPath(home, env);
|
|
12
|
+
if (!enabled) {
|
|
13
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
14
|
+
fs.writeFileSync(marker, "revoked\n", { mode: 0o600 });
|
|
15
|
+
fs.chmodSync(marker, 0o600);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
fs.unlinkSync(marker);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ENOENT")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=anywhere-runtime-revocation-marker.js.map
|
package/dist/lib/daemon/run.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface DaemonLockConflictOutcome {
|
|
|
29
29
|
export declare function daemonLockConflictOutcome(lock: DaemonLockResult, anywhereEnabled: boolean): DaemonLockConflictOutcome | null;
|
|
30
30
|
/** hqd is available on every platform while Anywhere is enabled; its listener fails closed if setup is unsafe. */
|
|
31
31
|
export declare function shouldStartHqd(anywhereEnabled: boolean): boolean;
|
|
32
|
+
/** Resolve the supervisor's runtime-access decision and reflect it in the local wrapper marker. */
|
|
33
|
+
export declare function refreshHqdRuntimeDecision(readDecision: () => Promise<boolean>, home: string, env: NodeJS.ProcessEnv, onMarkerError?: (error: unknown) => void): Promise<boolean>;
|
|
32
34
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
33
35
|
export declare function handoffForPackageUpdateRequest(requested: boolean, restartDaemon: () => void): void;
|
|
34
36
|
export interface RunDaemonOptions {
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -30,6 +30,7 @@ import { retireLegacyMeshUnits } from "./migrate.js";
|
|
|
30
30
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
31
31
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
32
32
|
import { registryDir, registryFile } from "../registry/registry.js";
|
|
33
|
+
import { updateAnywhereRuntimeRevocationMarker } from "../anywhere-runtime-revocation-marker.js";
|
|
33
34
|
import { daemonRootRefusal } from "./install.js";
|
|
34
35
|
import { detectMachineType } from "./machine-type.js";
|
|
35
36
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
@@ -93,6 +94,17 @@ export function daemonLockConflictOutcome(lock, anywhereEnabled) {
|
|
|
93
94
|
export function shouldStartHqd(anywhereEnabled) {
|
|
94
95
|
return anywhereEnabled;
|
|
95
96
|
}
|
|
97
|
+
/** Resolve the supervisor's runtime-access decision and reflect it in the local wrapper marker. */
|
|
98
|
+
export async function refreshHqdRuntimeDecision(readDecision, home, env, onMarkerError = () => { }) {
|
|
99
|
+
const enabled = await readDecision();
|
|
100
|
+
try {
|
|
101
|
+
updateAnywhereRuntimeRevocationMarker(enabled, home, env);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
onMarkerError(error);
|
|
105
|
+
}
|
|
106
|
+
return enabled;
|
|
107
|
+
}
|
|
96
108
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
97
109
|
export function handoffForPackageUpdateRequest(requested, restartDaemon) {
|
|
98
110
|
if (requested)
|
|
@@ -897,13 +909,13 @@ export async function runDaemon(opts) {
|
|
|
897
909
|
onError: (name) => log("warn", `hqd person setting read failed (${name}); keeping the last read value for up to ${HQD_PERSON_SETTING_GRACE_MS / 60_000} minutes`),
|
|
898
910
|
}), { graceMs: HQD_PERSON_SETTING_GRACE_MS });
|
|
899
911
|
const hqdListener = await startHqdRuntimeListener({
|
|
900
|
-
isEnabled: async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
|
|
912
|
+
isEnabled: async () => refreshHqdRuntimeDecision(async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
|
|
901
913
|
refreshFlag: async () => {
|
|
902
914
|
const { refreshFlagRegistrySnapshot } = await import("../flag-registry.js");
|
|
903
915
|
await refreshFlagRegistrySnapshot(5_000);
|
|
904
916
|
},
|
|
905
917
|
readSetting: readPersonSetting,
|
|
906
|
-
})),
|
|
918
|
+
})), home, env, () => log("warn", "hqd runtime revocation marker update failed")),
|
|
907
919
|
startServer: async () => {
|
|
908
920
|
const startConfig = readDaemonConfig(paths.configPath);
|
|
909
921
|
return startHqdServer({
|
|
@@ -959,8 +971,9 @@ export async function runDaemon(opts) {
|
|
|
959
971
|
releaseDaemonLock(paths.lockPath);
|
|
960
972
|
resolve(exitCode);
|
|
961
973
|
// Pipes, sockets and unrelated timers can outlive a clean shutdown.
|
|
962
|
-
//
|
|
963
|
-
|
|
974
|
+
// Services and the lock are already stopped; unrelated handles should
|
|
975
|
+
// not hold a restart or signal exit open for another service interval.
|
|
976
|
+
setTimeout(() => process.exit(exitCode), 1_000).unref();
|
|
964
977
|
});
|
|
965
978
|
};
|
|
966
979
|
/** Exit for a restart but leave the services running for the next daemon to adopt. */
|
|
@@ -23,6 +23,7 @@ import { defaultMcpToken, HqMcpBridge, jsonResult, McpOpError, runWorker } from
|
|
|
23
23
|
import { authenticateMachineCredentials, machineTokenExpiresAt, MachineLoginError, parseStdinMachineCredentials } from "./machine-login.js";
|
|
24
24
|
import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
|
|
25
25
|
import { HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
|
|
26
|
+
import { updateAnywhereRuntimeRevocationMarker } from "../anywhere-runtime-revocation-marker.js";
|
|
26
27
|
import { readHqAnywherePersonSetting } from "../anywhere-person-setting.js";
|
|
27
28
|
import { createBoundedDaemonFlagMonitor } from "./flag-monitor.js";
|
|
28
29
|
import { readDaemonFlag } from "./flag.js";
|
|
@@ -416,6 +417,12 @@ export async function startHqdServer(opts) {
|
|
|
416
417
|
return personSetting === true;
|
|
417
418
|
},
|
|
418
419
|
onChange: (enabled) => {
|
|
420
|
+
try {
|
|
421
|
+
updateAnywhereRuntimeRevocationMarker(enabled, env.HOME?.trim() || undefined, env);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
log("warn", `hqd runtime revocation marker update failed (${error instanceof Error ? error.name : "UnknownError"})`);
|
|
425
|
+
}
|
|
419
426
|
if (!enabled) {
|
|
420
427
|
flagRevocationGeneration += 1;
|
|
421
428
|
ephemeralMachineToken = undefined;
|
|
@@ -71,7 +71,18 @@ export interface RemoteUsageResponse {
|
|
|
71
71
|
apps?: RemoteAppUsage[];
|
|
72
72
|
}
|
|
73
73
|
/** Answer for one app: its own numbers, or the company numbers when it shares the company cluster. */
|
|
74
|
-
export
|
|
74
|
+
export interface RemoteRemovedAppUsage {
|
|
75
|
+
companyUid: string;
|
|
76
|
+
appId: string;
|
|
77
|
+
mode: "app-cluster" | "company-cluster";
|
|
78
|
+
status: "detached" | "purged";
|
|
79
|
+
removedAt: string;
|
|
80
|
+
retainUntil: string | null;
|
|
81
|
+
purgedAt: string | null;
|
|
82
|
+
billed: false;
|
|
83
|
+
exempt: boolean;
|
|
84
|
+
}
|
|
85
|
+
export type RemoteAppUsageResponse = RemoteRemovedAppUsage | (RemoteAppUsage & {
|
|
75
86
|
companyUid: string;
|
|
76
87
|
mode: "app-cluster";
|
|
77
88
|
status?: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { RemoteAppUsage, RemoteAppUsageResponse, RemoteUsageResponse } from "./control-plane.js";
|
|
1
|
+
import type { RemoteAppUsage, RemoteAppUsageResponse, RemoteRemovedAppUsage, RemoteUsageResponse } from "./control-plane.js";
|
|
2
2
|
export declare function formatBytes(bytes: number): string;
|
|
3
3
|
export declare function formatUsage(usage: RemoteUsageResponse): string;
|
|
4
4
|
export declare function formatAppUsage(usage: RemoteAppUsage): string;
|
|
5
|
+
export declare function formatRemovedAppUsage(usage: RemoteRemovedAppUsage): string;
|
|
5
6
|
export declare function formatAppUsageResponse(response: RemoteAppUsageResponse): string;
|
|
6
7
|
export declare function formatCompanyApps(apps: RemoteAppUsage[]): string;
|
|
7
8
|
//# sourceMappingURL=usage-format.d.ts.map
|
|
@@ -80,7 +80,26 @@ export function formatAppUsage(usage) {
|
|
|
80
80
|
: `Last measured ${usage.measuredAt}. Numbers can lag by a few minutes.`);
|
|
81
81
|
return lines.join("\n");
|
|
82
82
|
}
|
|
83
|
+
function day(iso) {
|
|
84
|
+
return iso.slice(0, 10);
|
|
85
|
+
}
|
|
86
|
+
export function formatRemovedAppUsage(usage) {
|
|
87
|
+
const lines = [`The database for app ${usage.appId} was removed on ${day(usage.removedAt)}.`];
|
|
88
|
+
if (usage.purgedAt) {
|
|
89
|
+
lines.push(`Its data was deleted for good on ${day(usage.purgedAt)}.`);
|
|
90
|
+
}
|
|
91
|
+
else if (usage.retainUntil) {
|
|
92
|
+
lines.push(`Its data is kept until ${day(usage.retainUntil)}, then deleted for good.`);
|
|
93
|
+
}
|
|
94
|
+
lines.push("It is not billed.");
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
function isRemoved(response) {
|
|
98
|
+
return "status" in response && (response.status === "detached" || response.status === "purged");
|
|
99
|
+
}
|
|
83
100
|
export function formatAppUsageResponse(response) {
|
|
101
|
+
if (isRemoved(response))
|
|
102
|
+
return formatRemovedAppUsage(response);
|
|
84
103
|
if (response.mode === "company-cluster") {
|
|
85
104
|
return [
|
|
86
105
|
`App ${response.appId} uses the shared company database, so these are the company numbers.`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER } from '../anywhere-runtime-flag-key.js';
|
|
4
5
|
const marker = '# hq-anywhere provider sign-in wrapper';
|
|
5
6
|
const PROVIDER_PATH_RC_FILES = ['.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile'];
|
|
6
7
|
const wrapperHeader = (provider) => `#!/bin/sh\n${marker}: ${provider}\n`;
|
|
@@ -76,6 +77,18 @@ function wrapperSource(provider) {
|
|
|
76
77
|
` ${provider === 'claude' ? '/login|auth' : 'login'}) exec "$real" "$@" ;;`,
|
|
77
78
|
'esac',
|
|
78
79
|
'',
|
|
80
|
+
'# The local marker mirrors registryDir: trim the override and use ~/.hq when empty.',
|
|
81
|
+
'registry_dir=${HQ_REGISTRY_DIR:-}',
|
|
82
|
+
"registry_dir=$(printf '%s' \"$registry_dir\" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')",
|
|
83
|
+
'if [ -z "$registry_dir" ]; then',
|
|
84
|
+
' registry_dir=${HOME:-}',
|
|
85
|
+
' [ -n "$registry_dir" ] && registry_dir="$registry_dir/.hq"',
|
|
86
|
+
'fi',
|
|
87
|
+
'if [ -n "$registry_dir" ]; then',
|
|
88
|
+
' case "$registry_dir" in /*) ;; *) registry_dir="$PWD/$registry_dir" ;; esac',
|
|
89
|
+
` if [ -e "$registry_dir/${HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER}" ]; then exec "$real" "$@"; fi`,
|
|
90
|
+
'fi',
|
|
91
|
+
'',
|
|
79
92
|
'log_dir="$HOME/.hq/logs"',
|
|
80
93
|
'mkdir -p "$log_dir" 2>/dev/null || :',
|
|
81
94
|
'log_file="$log_dir/provider-auth.log"',
|
package/dist/lib/lanes/loop.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface LoopResult {
|
|
|
60
60
|
session_id?: string;
|
|
61
61
|
resumed?: boolean;
|
|
62
62
|
fresh_fallback?: boolean;
|
|
63
|
+
question_id?: string;
|
|
64
|
+
decision?: {
|
|
65
|
+
question_id: string;
|
|
66
|
+
choice: string;
|
|
67
|
+
notes?: string;
|
|
68
|
+
at: string;
|
|
69
|
+
};
|
|
63
70
|
}
|
|
64
71
|
export interface Section8Handoff {
|
|
65
72
|
schema: typeof PHASE_HANDOFF_SCHEMA;
|
|
@@ -103,6 +110,7 @@ export interface LoopRunnerOptions {
|
|
|
103
110
|
envelopeId: string;
|
|
104
111
|
attempt: number;
|
|
105
112
|
error: string;
|
|
113
|
+
questionId: string;
|
|
106
114
|
}): Promise<void> | void;
|
|
107
115
|
defaultDeadlineSeconds?: number;
|
|
108
116
|
now?: () => Date;
|
|
@@ -120,7 +128,6 @@ interface StoredEnvelope {
|
|
|
120
128
|
envelope_id: string;
|
|
121
129
|
queued_at: string;
|
|
122
130
|
deadline_budget_seconds?: number;
|
|
123
|
-
deadline_at_ms?: number;
|
|
124
131
|
envelope: LoopEnvelope;
|
|
125
132
|
}
|
|
126
133
|
/** Validate both hq-cli phase envelopes and the conduct protocol §8 shape. */
|
|
@@ -148,6 +155,21 @@ export declare function interruptLoopQueue(runDir: string): {
|
|
|
148
155
|
withdrawn: string[];
|
|
149
156
|
already_picked_up: string[];
|
|
150
157
|
};
|
|
158
|
+
export declare function interruptLoopQueueForStory(runDir: string, storyId: string, phase?: string): {
|
|
159
|
+
withdrawn: string[];
|
|
160
|
+
already_picked_up: string[];
|
|
161
|
+
};
|
|
162
|
+
export declare function applyLoopStallAnswer(input: {
|
|
163
|
+
runDir: string;
|
|
164
|
+
questionId: string;
|
|
165
|
+
choice: string;
|
|
166
|
+
notes?: string;
|
|
167
|
+
at: string;
|
|
168
|
+
}): {
|
|
169
|
+
lane_id: string;
|
|
170
|
+
envelope_id: string;
|
|
171
|
+
action: "requeued" | "parked";
|
|
172
|
+
} | undefined;
|
|
151
173
|
export declare function loopStatus(runDir: string, laneId: string): LoopStatus;
|
|
152
174
|
export declare function setLoopControllerPid(runDir: string, laneId: string, pid: number | undefined): void;
|
|
153
175
|
export declare function clearLoopControllerPid(runDir: string, laneId: string, pid: number): void;
|
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -14,6 +14,16 @@ export function loopAdmissionPath(state, hasPendingResume, initialAdmission) {
|
|
|
14
14
|
return "deferred";
|
|
15
15
|
return "resume";
|
|
16
16
|
}
|
|
17
|
+
function asidePath(file, marker) {
|
|
18
|
+
const parsed = path.parse(file);
|
|
19
|
+
let suffix = 1;
|
|
20
|
+
let candidate;
|
|
21
|
+
do {
|
|
22
|
+
candidate = path.join(parsed.dir, `${parsed.name}.${marker}.${suffix}${parsed.ext}`);
|
|
23
|
+
suffix += 1;
|
|
24
|
+
} while (fs.existsSync(candidate));
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
17
27
|
function isRecord(value) {
|
|
18
28
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
29
|
}
|
|
@@ -331,11 +341,49 @@ export function enqueueLoopEnvelope(input) {
|
|
|
331
341
|
}
|
|
332
342
|
})
|
|
333
343
|
.filter((record) => record !== undefined);
|
|
334
|
-
|
|
344
|
+
const withdrawnDir = path.join(input.runDir, "loop", "withdrawn");
|
|
345
|
+
const stalledDir = path.join(input.runDir, "loop", "stalled");
|
|
346
|
+
const reusableRecords = records.filter((record) => record.envelope_id === envelopeId);
|
|
347
|
+
const terminalResult = fs.existsSync(path.join(resultsDir(input.runDir), resultName(envelopeId)))
|
|
348
|
+
? readJson(path.join(resultsDir(input.runDir), resultName(envelopeId))) : undefined;
|
|
349
|
+
const reusable = reusableRecords.length > 0 && reusableRecords.every((record) => [stalledDir, withdrawnDir].some((dir) => envelopeFiles(dir).some((file) => {
|
|
350
|
+
try {
|
|
351
|
+
return fileEnvelope(file).envelope_id === record.envelope_id;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}))) && (!isRecord(terminalResult) || terminalResult.status === "stalled");
|
|
357
|
+
if ((records.some((record) => record.envelope_id === envelopeId) && !reusable) ||
|
|
358
|
+
(terminalResult !== undefined && (!reusable || !isRecord(terminalResult) || terminalResult.status !== "stalled"))) {
|
|
335
359
|
throw new Error(`envelope id ${envelopeId} was already used in this loop lane`);
|
|
336
360
|
}
|
|
361
|
+
if (reusable) {
|
|
362
|
+
for (const file of envelopeFiles(withdrawnDir).concat(envelopeFiles(stalledDir))) {
|
|
363
|
+
try {
|
|
364
|
+
if (fileEnvelope(file).envelope_id === envelopeId)
|
|
365
|
+
fs.renameSync(file, asidePath(file, path.basename(path.dirname(file))));
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error.code !== "ENOENT")
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (terminalResult !== undefined) {
|
|
373
|
+
const resultFile = path.join(resultsDir(input.runDir), resultName(envelopeId));
|
|
374
|
+
fs.renameSync(resultFile, asidePath(resultFile, "stalled"));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
337
377
|
const incomingStop = envelope.kind === "stop";
|
|
338
|
-
|
|
378
|
+
const blockingStop = [pendingDir(input.runDir), activeDir(input.runDir)].some((scanDir) => envelopeFiles(scanDir).some((file) => {
|
|
379
|
+
try {
|
|
380
|
+
return fileEnvelope(file).envelope.kind === "stop";
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}));
|
|
386
|
+
if (blockingStop) {
|
|
339
387
|
throw new Error(incomingStop ? "a stop envelope is already queued or active" : "cannot enqueue a phase after a stop envelope");
|
|
340
388
|
}
|
|
341
389
|
const sequence = nextQueueSequence(input.runDir);
|
|
@@ -405,6 +453,77 @@ export function interruptLoopQueue(runDir) {
|
|
|
405
453
|
}
|
|
406
454
|
return { withdrawn, already_picked_up: alreadyPickedUp };
|
|
407
455
|
}
|
|
456
|
+
export function interruptLoopQueueForStory(runDir, storyId, phase) {
|
|
457
|
+
const withdrawn = [];
|
|
458
|
+
const alreadyPickedUp = [];
|
|
459
|
+
const matches = (record) => {
|
|
460
|
+
const envelope = record.envelope;
|
|
461
|
+
return envelope.story_id === storyId && (phase === undefined || envelope.phase === phase);
|
|
462
|
+
};
|
|
463
|
+
for (const file of envelopeFiles(pendingDir(runDir))) {
|
|
464
|
+
const record = fileEnvelope(file);
|
|
465
|
+
if (!matches(record))
|
|
466
|
+
continue;
|
|
467
|
+
const destination = path.join(runDir, "loop", "withdrawn", path.basename(file));
|
|
468
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
469
|
+
fs.renameSync(file, destination);
|
|
470
|
+
withdrawn.push(record.envelope_id);
|
|
471
|
+
}
|
|
472
|
+
for (const file of envelopeFiles(activeDir(runDir))) {
|
|
473
|
+
const record = fileEnvelope(file);
|
|
474
|
+
if (matches(record))
|
|
475
|
+
alreadyPickedUp.push(record.envelope_id);
|
|
476
|
+
}
|
|
477
|
+
return { withdrawn, already_picked_up: alreadyPickedUp };
|
|
478
|
+
}
|
|
479
|
+
export function applyLoopStallAnswer(input) {
|
|
480
|
+
const results = fs.existsSync(resultsDir(input.runDir))
|
|
481
|
+
? fs.readdirSync(resultsDir(input.runDir)).filter((name) => name.endsWith(".json")) : [];
|
|
482
|
+
const resultPath = results.map((name) => path.join(resultsDir(input.runDir), name)).find((file) => {
|
|
483
|
+
try {
|
|
484
|
+
const result = readJson(file);
|
|
485
|
+
return isRecord(result) && result.status === "stalled" && result.question_id === input.questionId;
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (error instanceof SyntaxError)
|
|
489
|
+
return false;
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
if (!resultPath)
|
|
494
|
+
return undefined;
|
|
495
|
+
const result = readJson(resultPath);
|
|
496
|
+
const envelopeFile = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => {
|
|
497
|
+
try {
|
|
498
|
+
return fileEnvelope(file).envelope_id === result.envelope_id;
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
if (!envelopeFile)
|
|
505
|
+
throw new Error(`stalled loop envelope ${result.envelope_id} is missing`);
|
|
506
|
+
const record = fileEnvelope(envelopeFile);
|
|
507
|
+
const action = input.choice === "Retry with a longer deadline" ? "requeued" : "parked";
|
|
508
|
+
if (action === "requeued") {
|
|
509
|
+
record.deadline_budget_seconds = Math.max(1, record.deadline_budget_seconds ?? phaseDeadlineBudget(record.envelope, 3600)) * 2;
|
|
510
|
+
const head = path.join(pendingDir(input.runDir), `0000000000000-answer-${path.basename(envelopeFile)}`);
|
|
511
|
+
fs.mkdirSync(pendingDir(input.runDir), { recursive: true });
|
|
512
|
+
fs.renameSync(envelopeFile, head);
|
|
513
|
+
fs.renameSync(resultPath, asidePath(resultPath, "stalled"));
|
|
514
|
+
const pending = fileEnvelope(head);
|
|
515
|
+
pending.deadline_budget_seconds = record.deadline_budget_seconds;
|
|
516
|
+
atomicJson(head, pending);
|
|
517
|
+
persistStatus(input.runDir, path.basename(input.runDir), { stall_counts: { [record.envelope_id]: 0 } });
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
atomicJson(resultPath, {
|
|
521
|
+
...result,
|
|
522
|
+
decision: { question_id: input.questionId, choice: input.choice, ...(input.notes ? { notes: input.notes } : {}), at: input.at },
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return { lane_id: path.basename(input.runDir), envelope_id: result.envelope_id, action };
|
|
526
|
+
}
|
|
408
527
|
export function loopStatus(runDir, laneId) {
|
|
409
528
|
const loop = fs.existsSync(loopPath(runDir)) && isRecord(readJson(loopPath(runDir))) ? readJson(loopPath(runDir)) : {};
|
|
410
529
|
const active = envelopeFiles(activeDir(runDir)).map(fileEnvelope)[0];
|
|
@@ -578,22 +697,16 @@ export async function runLoopLane(options) {
|
|
|
578
697
|
const previousSession = sessions.get(sessionKey);
|
|
579
698
|
const startedAt = now().toISOString();
|
|
580
699
|
const startedAtMs = Date.parse(startedAt);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
firstDeadline.set(record.envelope_id, budget);
|
|
589
|
-
record.deadline_budget_seconds = budget;
|
|
590
|
-
deadlineAtMs = startedAtMs + budget * 1000;
|
|
591
|
-
}
|
|
592
|
-
record.deadline_at_ms = deadlineAtMs;
|
|
593
|
-
}
|
|
700
|
+
const absoluteDeadline = envelope.deadline;
|
|
701
|
+
const budget = record.deadline_budget_seconds ?? firstDeadline.get(record.envelope_id) ??
|
|
702
|
+
(typeof absoluteDeadline === "string"
|
|
703
|
+
? Math.max(1, Math.round((Date.parse(absoluteDeadline) - startedAtMs) / 1000))
|
|
704
|
+
: phaseDeadlineBudget(envelope, defaultDeadlineSeconds));
|
|
705
|
+
firstDeadline.set(record.envelope_id, budget);
|
|
706
|
+
record.deadline_budget_seconds = budget;
|
|
594
707
|
atomicJson(file, record);
|
|
595
708
|
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id), stall_counts: Object.fromEntries(stalls) });
|
|
596
|
-
const timer = abortAfter(
|
|
709
|
+
const timer = abortAfter(budget * 1000, options.signal);
|
|
597
710
|
try {
|
|
598
711
|
let result;
|
|
599
712
|
let fallback = false;
|
|
@@ -673,9 +786,11 @@ export async function runLoopLane(options) {
|
|
|
673
786
|
continue;
|
|
674
787
|
}
|
|
675
788
|
const message = error instanceof Error ? error.message : String(error);
|
|
676
|
-
const
|
|
789
|
+
const questionId = options.raiseQuestion ? randomUUID() : undefined;
|
|
790
|
+
const result = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", started_at: startedAt, finished_at: now().toISOString(), error: message, ...(questionId ? { question_id: questionId } : {}) };
|
|
677
791
|
writeResultAt(options.runDir, file, record, result);
|
|
678
|
-
|
|
792
|
+
if (questionId)
|
|
793
|
+
await options.raiseQuestion?.({ envelope, envelopeId: record.envelope_id, attempt, error: message, questionId });
|
|
679
794
|
completed += 1;
|
|
680
795
|
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
681
796
|
releaseRunner();
|
|
@@ -715,7 +830,7 @@ function writeResultAt(runDir, activeFile, record, result) {
|
|
|
715
830
|
summary: stalled ? "Phase stalled twice and needs operator direction." : "Phase execution failed.",
|
|
716
831
|
files_changed: [], commits: [],
|
|
717
832
|
back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
|
|
718
|
-
context_for_next: result.error ?? result.status,
|
|
833
|
+
context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
|
|
719
834
|
};
|
|
720
835
|
atomicJson(path.resolve(externalPath), failure);
|
|
721
836
|
}
|
|
@@ -10,6 +10,11 @@ export interface QuestionBody {
|
|
|
10
10
|
options: string[];
|
|
11
11
|
recommended?: string;
|
|
12
12
|
notes?: string;
|
|
13
|
+
loop_stall?: {
|
|
14
|
+
lane_id: string;
|
|
15
|
+
envelope_id: string;
|
|
16
|
+
attempt: number;
|
|
17
|
+
};
|
|
13
18
|
}
|
|
14
19
|
export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
|
|
15
20
|
/** Errors that make an envelope ask unsafe or impossible to persist. */
|
|
@@ -12,6 +12,7 @@ import { compareLaneQueueOrder, compareLaneQueueOrderWithLeads, freeClaudeAccoun
|
|
|
12
12
|
import { accountStatus, isCredentialProven, resolveAccount } from "./accounts.js";
|
|
13
13
|
import { effectiveAccountHome } from "./account-home.js";
|
|
14
14
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
15
|
+
import { loopStatus } from "./loop.js";
|
|
15
16
|
import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
16
17
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
17
18
|
import { laneDepthCap } from "./depth-cap.js";
|
|
@@ -3600,6 +3601,8 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
3600
3601
|
const current = readLane(hqRoot, lane.lane_id);
|
|
3601
3602
|
if (current.state !== "awaiting_input")
|
|
3602
3603
|
continue;
|
|
3604
|
+
if (current.loop_mode === true && loopStatus(laneRunDir(hqRoot, current.lane_id), current.lane_id).state === "parked")
|
|
3605
|
+
continue;
|
|
3603
3606
|
const pending = questions.filter((q) => q.origin.id === lane.lane_id && q.status === "pending");
|
|
3604
3607
|
const answered = questions.filter((q) => q.origin.id === lane.lane_id && q.status === "answered");
|
|
3605
3608
|
if (pending.length > 0 || answered.length === 0)
|