@indigoai-us/hq-cli 5.301.0 → 5.302.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 +22 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +99 -12
- package/dist/command-catalog.generated.d.ts +4 -1
- package/dist/command-catalog.generated.js +5 -1
- package/dist/commands/bot.js +1 -0
- package/dist/commands/core-checkpoint.js +1 -0
- package/dist/commands/core-handoff-fast.js +1 -0
- package/dist/commands/core-handoff.js +2 -0
- package/dist/commands/deploy-comments.js +5 -2
- package/dist/commands/integrations-connect.d.ts +3 -0
- package/dist/commands/integrations-connect.js +21 -2
- package/dist/commands/lanes-message.js +33 -3
- package/dist/commands/lanes-questions.d.ts +4 -2
- package/dist/commands/lanes-questions.js +123 -2
- package/dist/commands/lanes-resume.js +25 -4
- package/dist/commands/lanes-shared.d.ts +13 -1
- package/dist/commands/lanes-watch.js +5 -1
- package/dist/commands/lanes.js +91 -9
- package/dist/commands/monitor.js +17 -2
- package/dist/commands/report.js +5 -2
- package/dist/commands/rescue.d.ts +4 -0
- package/dist/commands/rescue.js +27 -17
- package/dist/lib/agent-kit/fallback.d.ts +1 -0
- package/dist/lib/agent-kit/fallback.js +1 -0
- package/dist/lib/bot/daemon.d.ts +5 -1
- package/dist/lib/bot/daemon.js +4 -4
- package/dist/lib/core-utils/soft-timeout.js +1 -1
- package/dist/lib/deploy-apps.d.ts +13 -0
- package/dist/lib/deploy-apps.js +24 -0
- package/dist/lib/index-render/projects.js +82 -5
- package/dist/lib/lanes/delivery.d.ts +7 -3
- package/dist/lib/lanes/delivery.js +18 -8
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +13 -3
- package/dist/lib/lanes/keep-alive.js +29 -5
- package/dist/lib/lanes/outpost-dispatch.d.ts +5 -0
- package/dist/lib/lanes/outpost-dispatch.js +12 -0
- package/dist/lib/lanes/reconcile.d.ts +1 -0
- package/dist/lib/lanes/reconcile.js +358 -38
- package/dist/lib/lanes/spawn.d.ts +1 -0
- package/dist/lib/lanes/spawn.js +20 -10
- package/dist/lib/lanes/store.js +24 -0
- package/dist/lib/lanes/story-ledger.d.ts +2 -0
- package/dist/lib/lanes/story-ledger.js +8 -0
- package/dist/lib/lanes/types.d.ts +26 -4
- package/dist/lib/lanes/types.js +33 -1
- package/dist/lib/lanes/watch.d.ts +1 -0
- package/dist/lib/lanes/watch.js +19 -6
- package/dist/lib/lanes/watcher.js +139 -9
- package/dist/lib/mesh/live/daemon/run.js +36 -2
- package/dist/lib/search-index/background.d.ts +1 -0
- package/dist/lib/search-index/background.js +1 -0
- package/dist/utils/update-command-supervisor.cjs +1 -0
- package/dist/utils/version-gate.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.302.0] — 2026-10-01
|
|
6
|
+
|
|
7
|
+
- Generate company project indexes while preserving hand-written content unless `--accept` is used.
|
|
8
|
+
|
|
9
|
+
- Follow all hq-deploy app-list pages when resolving apps for comments and report publishing.
|
|
10
|
+
- Let a senior message or resume an `awaiting_input` lane when its pending question is gone or cannot be reached from the senior's host. Pending questions remain answer-first, including across registered Outpost routes.
|
|
11
|
+
- A senior can resume or send a fallback message to an `awaiting_input` lane when its pending questions are addressed to another audience. Once the resume is accepted, those questions are cancelled on their owner; unreachable owners are reported while the lane continues.
|
|
12
|
+
- When a blocker escalation targets a session without a registered monitor inbox, record the reason and deliver it through the lane's existing inbox instead of retrying the failed escalation every watcher pass.
|
|
13
|
+
- `hq integrations disconnect` can now remove company integrations that were created outside the app catalog.
|
|
14
|
+
- Lead envelopes can set `next_check_at` to defer automatic keep-alive resumes
|
|
15
|
+
until a scheduled check, with pending work continuing to resume immediately.
|
|
16
|
+
- Reconcile records deduplicated keep-alive skip reasons in each lane's watcher event log.
|
|
17
|
+
- Add a company-scoped reconcile gate that closes local interrupted worker lanes after 24 hours when all carried project stories are done or dropped and no pending work remains.
|
|
18
|
+
- Skip refreshing failed-dispatch lane mirrors that never reached their Outpost owner.
|
|
19
|
+
- Keep lane keep-alive, retry-context, and monitor history readers moving past torn JSONL records.
|
|
20
|
+
- Refresh primary mirror heartbeats from fresh running Outpost reports before dead-host reconciliation.
|
|
21
|
+
- Report answerable awaiting-input questions as the `hq lanes show` delivery path, and retry blocker-freshness fallback messages after a failed append.
|
|
22
|
+
- Let `hq integrations connect` send an API key in a custom HTTP header.
|
|
23
|
+
- Bound `hq rescue` GitHub release and tag lookups to 30 seconds with a clear timeout error.
|
|
24
|
+
- hq-cli now logs live-board refresh failures and recovery while continuing to retry.
|
|
25
|
+
- On Windows, background jobs started by `/handoff` and search indexing no longer open console windows.
|
|
26
|
+
|
|
5
27
|
## [5.301.0] — 2026-10-01
|
|
6
28
|
- `hq meetings transcript` and `hq meetings notes` stop a stalled document download after two minutes and say so, instead of waiting forever.
|
|
7
29
|
|
|
@@ -11,12 +11,11 @@ set -euo pipefail
|
|
|
11
11
|
HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
12
12
|
cd "$HQ_ROOT"
|
|
13
13
|
|
|
14
|
+
COMPANIES_DIR="companies"
|
|
14
15
|
PROJECTS_DIR="personal/projects"
|
|
15
16
|
OUT="${PROJECTS_DIR}/INDEX.md"
|
|
16
17
|
DATE=$(date -u +%Y-%m-%d)
|
|
17
18
|
|
|
18
|
-
[[ -d "$PROJECTS_DIR" ]] || { echo "rebuild-projects-index: ${PROJECTS_DIR}/ missing, skipping" >&2; exit 0; }
|
|
19
|
-
|
|
20
19
|
sanitize_cell() {
|
|
21
20
|
printf '%s' "$1" | tr '\n' ' ' | sed -e 's/|/\\|/g' -e 's/ */ /g' -e 's/^ *//' -e 's/ *$//'
|
|
22
21
|
}
|
|
@@ -29,17 +28,23 @@ trunc() {
|
|
|
29
28
|
project_status() {
|
|
30
29
|
local project="$1"
|
|
31
30
|
local prd="$2"
|
|
31
|
+
local fallback="${3:-—}"
|
|
32
|
+
local expected_prd="${4:-}"
|
|
32
33
|
local state="workspace/orchestrator/${project}/state.json"
|
|
33
34
|
if [[ -f "$state" ]]; then
|
|
34
|
-
local
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
local state_prd
|
|
36
|
+
state_prd=$(jq -r '.prd_path // ""' "$state" 2>/dev/null || echo "")
|
|
37
|
+
if [[ -z "$expected_prd" || "$state_prd" == "$expected_prd" ]]; then
|
|
38
|
+
local raw
|
|
39
|
+
raw=$(jq -r '.status // "active"' "$state" 2>/dev/null || echo "active")
|
|
40
|
+
case "$raw" in
|
|
41
|
+
completed|complete) echo "complete" ;;
|
|
42
|
+
in_progress|active|started) echo "active" ;;
|
|
43
|
+
archived) echo "archived" ;;
|
|
44
|
+
*) echo "$raw" ;;
|
|
45
|
+
esac
|
|
46
|
+
return
|
|
47
|
+
fi
|
|
43
48
|
fi
|
|
44
49
|
if [[ -f "$prd" ]]; then
|
|
45
50
|
local total passes
|
|
@@ -50,9 +55,91 @@ project_status() {
|
|
|
50
55
|
return
|
|
51
56
|
fi
|
|
52
57
|
fi
|
|
53
|
-
echo "
|
|
58
|
+
echo "$fallback"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sha256_file() {
|
|
62
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
63
|
+
sha256sum "$1" | awk '{print $1}'
|
|
64
|
+
else
|
|
65
|
+
shasum -a 256 "$1" | awk '{print $1}'
|
|
66
|
+
fi
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
stamp_generated() {
|
|
70
|
+
local input="$1" output="$2" normalized hash
|
|
71
|
+
normalized=$(mktemp)
|
|
72
|
+
sed 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$input" > "$normalized"
|
|
73
|
+
hash=$(sha256_file "$normalized")
|
|
74
|
+
rm -f "$normalized"
|
|
75
|
+
awk -v h="$hash" '{print} /^> Auto-generated. Updated: / {print "<!-- hq-generated-sha256: " h " -->"}' "$input" > "$output"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
company_title() {
|
|
79
|
+
printf '%s\n' "$1" | awk -F'-' '{ for (i=1; i<=NF; i++) { $i = toupper(substr($i,1,1)) substr($i,2) } print }' OFS=' '
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
write_company_projects_index() {
|
|
83
|
+
local company="$1" company_root project_dir output tmp name prd description status
|
|
84
|
+
company_root="${COMPANIES_DIR}/${company}"
|
|
85
|
+
project_dir="${company_root}/projects"
|
|
86
|
+
output="${project_dir}/INDEX.md"
|
|
87
|
+
tmp=$(mktemp)
|
|
88
|
+
{
|
|
89
|
+
printf '# %s Projects\n\n' "$(company_title "$company")"
|
|
90
|
+
printf '> Auto-generated. Updated: %s\n\n' "$DATE"
|
|
91
|
+
echo '| Project | Status | Description |'
|
|
92
|
+
echo '|---------|--------|-------------|'
|
|
93
|
+
while IFS= read -r project; do
|
|
94
|
+
name=$(basename "$project")
|
|
95
|
+
[[ "$name" == _* ]] && continue
|
|
96
|
+
[[ "$name" == .* ]] && continue
|
|
97
|
+
prd="${project}/prd.json"
|
|
98
|
+
description=""
|
|
99
|
+
if [[ -f "$prd" ]]; then description=$(jq -r '.description // ""' "$prd" 2>/dev/null || echo ""); fi
|
|
100
|
+
status=$(project_status "$name" "$prd" "active" "$prd")
|
|
101
|
+
description=$(sanitize_cell "$description")
|
|
102
|
+
description=$(trunc "$description" 110)
|
|
103
|
+
[[ -z "$description" ]] && description="—"
|
|
104
|
+
printf '| `%s` | %s | %s |\n' "$name" "$status" "$description"
|
|
105
|
+
done < <(find "$project_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
|
|
106
|
+
} > "$tmp"
|
|
107
|
+
local existing=0 stored actual normalized
|
|
108
|
+
[[ -e "$output" ]] && existing=1
|
|
109
|
+
if [[ "$existing" == 1 ]]; then
|
|
110
|
+
if grep -q '<!-- hq:keep -->' "$output" || ! grep -q '^> Auto-generated. Updated: ' "$output"; then
|
|
111
|
+
echo "rebuild-projects-index: refused to overwrite ${output} (hand-written or kept)" >&2
|
|
112
|
+
rm -f "$tmp"
|
|
113
|
+
return 0
|
|
114
|
+
fi
|
|
115
|
+
stored=$(sed -n 's/^<!-- hq-generated-sha256: \([a-f0-9]*\) -->$/\1/p' "$output" | head -n 1)
|
|
116
|
+
if [[ -n "$stored" ]]; then
|
|
117
|
+
normalized=$(mktemp)
|
|
118
|
+
sed -e '/^<!-- hq-generated-sha256: [a-f0-9]* -->$/d' -e 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$output" > "$normalized"
|
|
119
|
+
actual=$(sha256_file "$normalized")
|
|
120
|
+
rm -f "$normalized"
|
|
121
|
+
if [[ "$stored" != "$actual" ]]; then
|
|
122
|
+
echo "rebuild-projects-index: refused to overwrite ${output} (generated content was edited)" >&2
|
|
123
|
+
rm -f "$tmp"
|
|
124
|
+
return 0
|
|
125
|
+
fi
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
stamp_generated "$tmp" "$output"
|
|
129
|
+
rm -f "$tmp"
|
|
130
|
+
echo "rebuild-projects-index: wrote ${output}" >&2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
while IFS= read -r dir; do
|
|
134
|
+
co=$(basename "$dir")
|
|
135
|
+
[[ "$co" == _* ]] && continue
|
|
136
|
+
[[ "$co" == .* ]] && continue
|
|
137
|
+
project_dir="${COMPANIES_DIR}/${co}/projects"
|
|
138
|
+
[[ -d "$project_dir" ]] && write_company_projects_index "$co"
|
|
139
|
+
done < <(find "$COMPANIES_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
|
|
140
|
+
|
|
141
|
+
[[ -d "$PROJECTS_DIR" ]] || { echo "rebuild-projects-index: ${PROJECTS_DIR}/ missing, skipping" >&2; exit 0; }
|
|
142
|
+
|
|
56
143
|
{
|
|
57
144
|
echo "# Projects"
|
|
58
145
|
echo ""
|
|
@@ -3122,6 +3122,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
3122
3122
|
}, {
|
|
3123
3123
|
readonly flags: "--token-stdin";
|
|
3124
3124
|
readonly description: "Read the API key from stdin";
|
|
3125
|
+
}, {
|
|
3126
|
+
readonly flags: "--auth-header <name>";
|
|
3127
|
+
readonly description: "Send the API key in this HTTP header (implies --auth key)";
|
|
3125
3128
|
}, {
|
|
3126
3129
|
readonly flags: "--auth <mode>";
|
|
3127
3130
|
readonly description: "Force the auth mode: none, key, or oauth (default: detect)";
|
|
@@ -8608,7 +8611,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8608
8611
|
readonly subcommands: readonly [];
|
|
8609
8612
|
}, {
|
|
8610
8613
|
readonly name: "resume";
|
|
8611
|
-
readonly description: "Resume an interrupted, done or blocked lane
|
|
8614
|
+
readonly description: "Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with the senior-only --from-failed option";
|
|
8612
8615
|
readonly aliases: readonly [];
|
|
8613
8616
|
readonly hidden: false;
|
|
8614
8617
|
readonly usage: "[options] <lane-id>";
|
|
@@ -4013,6 +4013,10 @@ export const COMMAND_CATALOG = [
|
|
|
4013
4013
|
"flags": "--token-stdin",
|
|
4014
4014
|
"description": "Read the API key from stdin"
|
|
4015
4015
|
},
|
|
4016
|
+
{
|
|
4017
|
+
"flags": "--auth-header <name>",
|
|
4018
|
+
"description": "Send the API key in this HTTP header (implies --auth key)"
|
|
4019
|
+
},
|
|
4016
4020
|
{
|
|
4017
4021
|
"flags": "--auth <mode>",
|
|
4018
4022
|
"description": "Force the auth mode: none, key, or oauth (default: detect)"
|
|
@@ -11100,7 +11104,7 @@ export const COMMAND_CATALOG = [
|
|
|
11100
11104
|
},
|
|
11101
11105
|
{
|
|
11102
11106
|
"name": "resume",
|
|
11103
|
-
"description": "Resume an interrupted, done or blocked lane
|
|
11107
|
+
"description": "Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with the senior-only --from-failed option",
|
|
11104
11108
|
"aliases": [],
|
|
11105
11109
|
"hidden": false,
|
|
11106
11110
|
"usage": "[options] <lane-id>",
|
package/dist/commands/bot.js
CHANGED
|
@@ -228,6 +228,7 @@ export function spawnDetachedRun(name, dir) {
|
|
|
228
228
|
const fd = fs.openSync(botLogPath(dir), "a");
|
|
229
229
|
const child = spawn(process.execPath, [entry, "bot", "run", name], {
|
|
230
230
|
detached: true,
|
|
231
|
+
windowsHide: true,
|
|
231
232
|
stdio: ["ignore", fd, fd],
|
|
232
233
|
env: { ...process.env, HQ_NO_UPDATE_CHECK: "1" },
|
|
233
234
|
});
|
|
@@ -1049,6 +1049,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
1049
1049
|
try {
|
|
1050
1050
|
child = spawn(backend, args, {
|
|
1051
1051
|
detached: true,
|
|
1052
|
+
windowsHide: true,
|
|
1052
1053
|
cwd: liveRoot,
|
|
1053
1054
|
stdio: ["ignore", logFd, logFd],
|
|
1054
1055
|
env: siblingEnv(process.env, backend),
|
|
@@ -802,6 +802,7 @@ function spawnBashDetached(script, args, cwd, root, logFile) {
|
|
|
802
802
|
cwd,
|
|
803
803
|
env: { ...process.env, HQ_ROOT: root },
|
|
804
804
|
detached: true,
|
|
805
|
+
windowsHide: true,
|
|
805
806
|
stdio: ["ignore", fd, fd],
|
|
806
807
|
});
|
|
807
808
|
child.once("error", (error) => appendLog(logFile, `${error.message}\n`));
|
|
@@ -841,6 +842,7 @@ async function launchHandoffPost(args, root) {
|
|
|
841
842
|
cwd: root,
|
|
842
843
|
env: { ...process.env, HQ_NATIVE_HANDOFF_POST_CHILD: "1" },
|
|
843
844
|
detached: true,
|
|
845
|
+
windowsHide: true,
|
|
844
846
|
stdio: "ignore",
|
|
845
847
|
});
|
|
846
848
|
child.once("error", (error) => resolve({ code: 127, stdout: "", stderr: `${error.message}\n` }));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deployApiRequest } from "../lib/deploy-api.js";
|
|
2
|
+
import { listDeployApps } from "../lib/deploy-apps.js";
|
|
2
3
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
3
4
|
import { readActiveCompanySlug } from "./skill.js";
|
|
4
5
|
class DeployCommentsError extends Error {
|
|
@@ -50,8 +51,10 @@ async function deployJson(token, route, orgHeaders) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
async function resolveAppId(token, appNameOrId, orgHeaders) {
|
|
53
|
-
const
|
|
54
|
-
|
|
54
|
+
const apps = await listDeployApps((cursor) => {
|
|
55
|
+
const route = cursor ? `/api/apps?cursor=${encodeURIComponent(cursor)}` : "/api/apps";
|
|
56
|
+
return deployJson(token, route, orgHeaders);
|
|
57
|
+
});
|
|
55
58
|
const byId = apps.find((app) => app && typeof app.id === "string" && app.id === appNameOrId);
|
|
56
59
|
const matches = byId
|
|
57
60
|
? [byId]
|
|
@@ -30,6 +30,7 @@ interface ConnectOptions {
|
|
|
30
30
|
displayName?: string;
|
|
31
31
|
token?: string;
|
|
32
32
|
tokenStdin?: boolean;
|
|
33
|
+
authHeader?: string;
|
|
33
34
|
auth?: string;
|
|
34
35
|
browser?: boolean;
|
|
35
36
|
timeout?: string;
|
|
@@ -48,6 +49,8 @@ interface ConnectOptions {
|
|
|
48
49
|
* than fall through to a default.
|
|
49
50
|
*/
|
|
50
51
|
export declare function assertAuthMode(value: string | undefined): void;
|
|
52
|
+
/** RFC 7230 token characters accepted for an HTTP field name. */
|
|
53
|
+
export declare function assertAuthHeader(value: string | undefined): void;
|
|
51
54
|
/** Single-quote a value for a copy-pasteable shell command. */
|
|
52
55
|
export declare function shellQuote(value: string): string;
|
|
53
56
|
/** Everything the connect verbs need to name one app to hq-pro. */
|
|
@@ -185,6 +185,12 @@ export function assertAuthMode(value) {
|
|
|
185
185
|
throw new IntegrationsCliError("--auth must be none, key, or oauth.", { expected: true });
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
+
/** RFC 7230 token characters accepted for an HTTP field name. */
|
|
189
|
+
export function assertAuthHeader(value) {
|
|
190
|
+
if (value !== undefined && !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(value)) {
|
|
191
|
+
throw new IntegrationsCliError("--auth-header must be a non-empty HTTP header name using RFC 7230 token characters.", { expected: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
188
194
|
/** Single-quote a value for a copy-pasteable shell command. */
|
|
189
195
|
export function shellQuote(value) {
|
|
190
196
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -904,7 +910,9 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
|
904
910
|
*/
|
|
905
911
|
async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
|
|
906
912
|
const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
|
|
907
|
-
const authMode = opts.
|
|
913
|
+
const authMode = opts.authHeader
|
|
914
|
+
? "key"
|
|
915
|
+
: opts.auth ?? target.authClass;
|
|
908
916
|
assertUserClientApplicable(opts, target, authMode);
|
|
909
917
|
assertScopesApplicable(opts, target, authMode);
|
|
910
918
|
if (authMode === "oauth") {
|
|
@@ -922,7 +930,13 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
922
930
|
const result = await installIntegration(token, companyUid, {
|
|
923
931
|
...target.ref,
|
|
924
932
|
...(bearerToken
|
|
925
|
-
? {
|
|
933
|
+
? {
|
|
934
|
+
authMode: "bearer",
|
|
935
|
+
bearerToken,
|
|
936
|
+
...(opts.authHeader
|
|
937
|
+
? { authScheme: { placement: "header", header: opts.authHeader } }
|
|
938
|
+
: {}),
|
|
939
|
+
}
|
|
926
940
|
: authMode === "none"
|
|
927
941
|
? { authMode: "none" }
|
|
928
942
|
: {}),
|
|
@@ -1173,6 +1187,7 @@ export function registerConnectCommands(integrations) {
|
|
|
1173
1187
|
.option("--display-name <name>", "Display name for a custom --mcp-url")
|
|
1174
1188
|
.option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
|
|
1175
1189
|
.option("--token-stdin", "Read the API key from stdin")
|
|
1190
|
+
.option("--auth-header <name>", "Send the API key in this HTTP header (implies --auth key)")
|
|
1176
1191
|
.option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
|
|
1177
1192
|
.option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
|
|
1178
1193
|
.option("--client-secret-stdin", "Read that app's client secret from stdin")
|
|
@@ -1183,6 +1198,10 @@ export function registerConnectCommands(integrations) {
|
|
|
1183
1198
|
.option("--json", "Machine-readable output")
|
|
1184
1199
|
.action(async (app, opts) => {
|
|
1185
1200
|
assertAuthMode(opts.auth);
|
|
1201
|
+
assertAuthHeader(opts.authHeader);
|
|
1202
|
+
if (opts.authHeader && opts.auth !== undefined && opts.auth !== "key") {
|
|
1203
|
+
throw new IntegrationsCliError("--auth-header can only be used with --auth key.", { expected: true });
|
|
1204
|
+
}
|
|
1186
1205
|
const token = await ensureCognitoIdToken();
|
|
1187
1206
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
1188
1207
|
if (await refuseIfAlreadyConnected(token, companyUid, app, opts))
|
|
@@ -247,6 +247,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
247
247
|
}
|
|
248
248
|
let lane = resolution.lane;
|
|
249
249
|
if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
|
|
250
|
+
const fallbackAt = lane.state === "awaiting_input" ? ctx.now().toISOString() : undefined;
|
|
250
251
|
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
251
252
|
const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
|
|
252
253
|
? undefined
|
|
@@ -262,9 +263,14 @@ export function createLaneMessageHandlers(deps) {
|
|
|
262
263
|
...opts,
|
|
263
264
|
...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
|
|
264
265
|
...(supersededMessageSessionId === undefined ? {} : { supersededMessageSessionId }),
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
}, {
|
|
267
|
+
...(resolution.remoteOnly
|
|
268
|
+
? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
|
|
269
|
+
: {}),
|
|
270
|
+
...(fallbackAt === undefined ? {} : {
|
|
271
|
+
afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "message", fallbackAt),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
268
274
|
return;
|
|
269
275
|
}
|
|
270
276
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
@@ -350,6 +356,19 @@ export function createLaneMessageHandlers(deps) {
|
|
|
350
356
|
if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
|
|
351
357
|
throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
|
|
352
358
|
}
|
|
359
|
+
const pendingAnswerableQuestion = lane.state === "awaiting_input"
|
|
360
|
+
? await deps.hasPendingQuestionForLane(ctx, lane)
|
|
361
|
+
: false;
|
|
362
|
+
if (pendingAnswerableQuestion) {
|
|
363
|
+
const message = deliveryRefusalReason(lane);
|
|
364
|
+
throw new LanesError("lane_not_accepting_messages", message, {
|
|
365
|
+
lane_id: laneId,
|
|
366
|
+
state: lane.state,
|
|
367
|
+
provider: lane.provider,
|
|
368
|
+
message,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const awaitingInputFallback = lane.state === "awaiting_input" && !pendingAnswerableQuestion;
|
|
353
372
|
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
354
373
|
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
355
374
|
const mechanism = laneDeliveryMechanism(lane);
|
|
@@ -536,9 +555,13 @@ export function createLaneMessageHandlers(deps) {
|
|
|
536
555
|
...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
|
|
537
556
|
});
|
|
538
557
|
}
|
|
558
|
+
let questionCancellation = null;
|
|
539
559
|
if (move?.queued) {
|
|
540
560
|
// Parked at the target's cap: reconcile resumes the session there.
|
|
541
561
|
markAccepted();
|
|
562
|
+
questionCancellation = awaitingInputFallback
|
|
563
|
+
? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
|
|
564
|
+
: null;
|
|
542
565
|
}
|
|
543
566
|
else {
|
|
544
567
|
const dispatched = await dispatchLaneDelivery(lane, {
|
|
@@ -637,6 +660,9 @@ export function createLaneMessageHandlers(deps) {
|
|
|
637
660
|
if (dispatched.mechanism === "resume")
|
|
638
661
|
resumed = dispatched.value;
|
|
639
662
|
markAccepted();
|
|
663
|
+
questionCancellation = awaitingInputFallback
|
|
664
|
+
? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
|
|
665
|
+
: null;
|
|
640
666
|
}
|
|
641
667
|
// Every accepted message moves the compaction reference forward.
|
|
642
668
|
const next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
@@ -679,6 +705,10 @@ export function createLaneMessageHandlers(deps) {
|
|
|
679
705
|
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
680
706
|
drain_hook: drainHook,
|
|
681
707
|
warning,
|
|
708
|
+
...(questionCancellation === null ? {} : {
|
|
709
|
+
cancelled_questions: questionCancellation.cancelled_question_ids,
|
|
710
|
+
question_cancellation_failures: questionCancellation.failures,
|
|
711
|
+
}),
|
|
682
712
|
resumed_session_id: resumed?.last_resume?.session_id ?? null,
|
|
683
713
|
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
684
714
|
? resumed.last_compaction
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { type QuestionBody, type QuestionRecord } from "../lib/lanes/index.js";
|
|
2
|
-
import type { Ctx, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
|
|
1
|
+
import { type QuestionBody, type LaneRecord, type QuestionRecord } from "../lib/lanes/index.js";
|
|
2
|
+
import type { Ctx, FallbackQuestionCancellationResult, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
|
|
3
3
|
export declare function resolveAnswerChoice(choice: string, options: string[], freeform: boolean): string;
|
|
4
4
|
export declare function createLaneQuestionsHandlers(deps: LaneQuestionsHandlerDeps): {
|
|
5
|
+
hasPendingQuestionForLane: (ctx: Ctx, lane: LaneRecord) => Promise<boolean>;
|
|
6
|
+
cancelFallbackQuestionsForLane: (ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string) => Promise<FallbackQuestionCancellationResult>;
|
|
5
7
|
runQuestionsList: (ctx: Ctx, opts: {
|
|
6
8
|
allHosts?: boolean;
|
|
7
9
|
for?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
1
|
+
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
2
2
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
3
3
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
4
4
|
import { appendCappedHistory, createQuestion, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
|
|
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
|
|
|
20
20
|
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
21
21
|
}
|
|
22
22
|
export function createLaneQuestionsHandlers(deps) {
|
|
23
|
-
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
23
|
+
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, casUpdateLane, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
24
24
|
function requireQuestionBody(hqRoot, question) {
|
|
25
25
|
const body = readQuestionBody(hqRoot, question);
|
|
26
26
|
if (!body) {
|
|
@@ -52,6 +52,16 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
52
52
|
return audience.kind === "user";
|
|
53
53
|
return audience.kind === filter.kind && audience.id === filter.id;
|
|
54
54
|
}
|
|
55
|
+
function questionAudienceMatchesSenior(audience, lane, ctx) {
|
|
56
|
+
if (audienceMatches(audience, lane.senior))
|
|
57
|
+
return true;
|
|
58
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
59
|
+
if (sessionId && audienceMatches(audience, { kind: "session", id: sessionId }))
|
|
60
|
+
return true;
|
|
61
|
+
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
62
|
+
return callerLaneId !== undefined && callerLaneId.length > 0 &&
|
|
63
|
+
audienceMatches(audience, { kind: "lane", id: callerLaneId });
|
|
64
|
+
}
|
|
55
65
|
function parseForFilter(raw, ctx) {
|
|
56
66
|
const trimmed = raw.trim();
|
|
57
67
|
if (trimmed === "me") {
|
|
@@ -384,6 +394,115 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
384
394
|
errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
|
|
385
395
|
};
|
|
386
396
|
}
|
|
397
|
+
async function cancelFallbackQuestionsForLane(ctx, lane, trigger, at) {
|
|
398
|
+
const hqRoot = ctx.hqRoot();
|
|
399
|
+
const caller = ctx.env.HQ_LANE_ID?.trim() || sessionIdFromEnv(ctx.env) || "user";
|
|
400
|
+
const reason = `Fallback ${trigger} by ${caller} at ${at}`;
|
|
401
|
+
const failures = [];
|
|
402
|
+
const cancelledQuestionIds = [];
|
|
403
|
+
const remoteOwner = lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane);
|
|
404
|
+
let pending;
|
|
405
|
+
try {
|
|
406
|
+
pending = remoteOwner
|
|
407
|
+
? (await readOutpostQuestionRecords(ctx, lane.host.id, effectiveLaneQuestionCompany(lane, hqRoot)))
|
|
408
|
+
.map(({ question }) => question)
|
|
409
|
+
.filter((question) => question.origin.id === lane.lane_id && question.status === "pending")
|
|
410
|
+
: loadAllQuestions(hqRoot).filter((question) => question.origin.id === lane.lane_id && question.status === "pending");
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
failures.push({ error: dispatchErrorMessage(err) });
|
|
414
|
+
pending = [];
|
|
415
|
+
}
|
|
416
|
+
for (const question of pending) {
|
|
417
|
+
try {
|
|
418
|
+
if (remoteOwner) {
|
|
419
|
+
const outpostId = lane.host.id;
|
|
420
|
+
const dispatched = await ctx.dispatchOutpostExec({
|
|
421
|
+
outpostId,
|
|
422
|
+
command: buildOutpostQuestionCancelCommand({ questionId: question.question_id, reason }),
|
|
423
|
+
timeoutSeconds: Math.ceil(CAPACITY_PROBE_TIMEOUT_MS / 1000),
|
|
424
|
+
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
425
|
+
});
|
|
426
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost question cancel");
|
|
427
|
+
const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
|
|
428
|
+
? remote.question
|
|
429
|
+
: null;
|
|
430
|
+
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0 ||
|
|
431
|
+
remote?.ok !== true || remote.action !== "questions.cancel" ||
|
|
432
|
+
remoteQuestion?.question_id !== question.question_id || remoteQuestion.status !== "cancelled") {
|
|
433
|
+
throw new LanesError("outpost_question_cancel_unconfirmed", `Outpost ${outpostId} did not confirm cancellation of question ${question.question_id}`, { lane_id: lane.lane_id, question_id: question.question_id, outpost_id: outpostId });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const cancellationEntry = {
|
|
438
|
+
hop: question.hop,
|
|
439
|
+
audience: resolveAudience(hqRoot, question, ctx),
|
|
440
|
+
at,
|
|
441
|
+
reason: "cancelled",
|
|
442
|
+
detail: reason,
|
|
443
|
+
};
|
|
444
|
+
const updated = casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
445
|
+
if (current.status !== "pending")
|
|
446
|
+
return current;
|
|
447
|
+
return {
|
|
448
|
+
...current,
|
|
449
|
+
status: "cancelled",
|
|
450
|
+
routing_history: appendCappedHistory(current.routing_history, cancellationEntry),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
if (updated.status !== "cancelled")
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
cancelledQuestionIds.push(question.question_id);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
failures.push({ question_id: question.question_id, error: dispatchErrorMessage(err) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (failures.length > 0) {
|
|
463
|
+
const failure = {
|
|
464
|
+
at,
|
|
465
|
+
caller,
|
|
466
|
+
trigger,
|
|
467
|
+
question_ids: failures.flatMap((entry) => entry.question_id ? [entry.question_id] : []),
|
|
468
|
+
error: failures.map((entry) => entry.error).join("; "),
|
|
469
|
+
};
|
|
470
|
+
try {
|
|
471
|
+
casUpdateLane(hqRoot, lane.lane_id, (current) => {
|
|
472
|
+
const history = current.fallback_question_cancellation_failures ?? [];
|
|
473
|
+
const duplicate = history.some((entry) => entry.at === failure.at && entry.caller === caller && entry.trigger === trigger);
|
|
474
|
+
return duplicate
|
|
475
|
+
? current
|
|
476
|
+
: {
|
|
477
|
+
...current,
|
|
478
|
+
fallback_question_cancellation_failures: [...history, failure].slice(-20),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
const message = `Could not record fallback question cancellation failure for lane ${lane.lane_id}: ${dispatchErrorMessage(err)}\n`;
|
|
484
|
+
process.stderr.write(message);
|
|
485
|
+
failures.push({ error: `Failure record could not be persisted: ${dispatchErrorMessage(err)}` });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { cancelled_question_ids: cancelledQuestionIds, failures };
|
|
489
|
+
}
|
|
490
|
+
async function hasPendingQuestionForLane(ctx, lane) {
|
|
491
|
+
const hqRoot = ctx.hqRoot();
|
|
492
|
+
const local = loadAllQuestions(hqRoot).some((question) => question.lane_id === lane.lane_id &&
|
|
493
|
+
question.status === "pending" &&
|
|
494
|
+
questionAudienceMatchesSenior(resolveAudience(hqRoot, question, ctx), lane, ctx));
|
|
495
|
+
if (local)
|
|
496
|
+
return true;
|
|
497
|
+
if (lane.host.kind !== "outpost" || laneIsHostedHere(ctx, lane))
|
|
498
|
+
return false;
|
|
499
|
+
// Reuse the owner lookup used by questions list/answer. An unreachable
|
|
500
|
+
// owner leaves no answerable question, so the message/resume path can recover.
|
|
501
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
502
|
+
return lookup.questions.some(({ question, lane: ownerLane, audience }) => ownerLane.lane_id === lane.lane_id &&
|
|
503
|
+
question.status === "pending" &&
|
|
504
|
+
questionAudienceMatchesSenior(audience, ownerLane, ctx));
|
|
505
|
+
}
|
|
387
506
|
function syncPrimaryQuestionDelivery(hqRoot, owner) {
|
|
388
507
|
const deliveredAt = owner.question.delivered_at;
|
|
389
508
|
if (!deliveredAt)
|
|
@@ -870,6 +989,8 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
870
989
|
});
|
|
871
990
|
}
|
|
872
991
|
return {
|
|
992
|
+
hasPendingQuestionForLane,
|
|
993
|
+
cancelFallbackQuestionsForLane,
|
|
873
994
|
runQuestionsList,
|
|
874
995
|
runQuestionsShow,
|
|
875
996
|
runQuestionsAnswer,
|