@indigoai-us/hq-cli 5.288.0 → 5.289.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 +6 -1
- package/assets/bot-workers/setup/skills/explain-hq.md +35 -0
- package/assets/bot-workers/setup/worker.yaml +25 -23
- package/dist/commands/lanes.js +157 -5
- package/dist/lib/bot/run.js +92 -2
- package/dist/lib/bot/setup-explain.d.ts +68 -0
- package/dist/lib/bot/setup-explain.js +141 -0
- package/dist/lib/lanes/store.js +30 -12
- package/dist/lib/lanes/types.d.ts +6 -2
- package/dist/lib/lanes/types.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [5.
|
|
5
|
+
## [5.289.0] — 2026-09-29
|
|
6
6
|
|
|
7
7
|
## [5.287.0] — 2026-09-29
|
|
8
8
|
- Group hook-timeout events by script while preserving triage diagnostics.
|
|
9
9
|
- Preserve safe slow-child script basenames in hook-timeout diagnostics.
|
|
10
10
|
- Cloud provisioning and retirement now stop after 15 seconds when a Vault API request stalls, with a clear timeout error.
|
|
11
|
+
- In the HQ desktop app, clicking "Explain HQ first" in the setup bot now shows the fixed explanation of HQ at once, with no model turn. The setup worker ships that text in `skills/explain-hq.md`, and the bot reads the same file when someone asks in their own words.
|
|
11
12
|
- Add native TypeScript implementations for four `hq core` worktree and
|
|
12
13
|
handoff scripts behind the default-off `core-native-handoff` flag. The
|
|
13
14
|
original shell assets remain bundled as the rollback path.
|
|
@@ -17,6 +18,10 @@
|
|
|
17
18
|
- `hq team-sync` keeps its successful no-team result, reports which company
|
|
18
19
|
directories are cloud-backed, and points to `hq sync pull --all` and
|
|
19
20
|
`hq sync status` for cloud-backed companies.
|
|
21
|
+
- An unconfirmed Outpost pickup acknowledgement keeps its mirror queued with
|
|
22
|
+
`outcome_unknown` and its command id. Reconcile uses a strict owner snapshot;
|
|
23
|
+
it marks the mirror `remote_lost` only after the 15-minute grace when the
|
|
24
|
+
owner confirms the lane is missing. Unreachable owners remain counted.
|
|
20
25
|
|
|
21
26
|
### Fixed
|
|
22
27
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: explain-hq
|
|
3
|
+
description: The fixed explanation of HQ, sent when the person asks for it at the start of setup. It is sent word for word, by you or by the HQ app.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Explaining HQ at the start
|
|
7
|
+
|
|
8
|
+
This is the one explanation of HQ that setup gives. It is sent word for word
|
|
9
|
+
and never reworded, shortened or added to.
|
|
10
|
+
|
|
11
|
+
- **When you send it** (they asked for the explanation in their own words, or
|
|
12
|
+
they clicked "Explain HQ first" and your turn does not start with a note
|
|
13
|
+
from the HQ app saying it already went out): send the text under "The
|
|
14
|
+
explanation" as written. Then, in the same message, one line saying you are
|
|
15
|
+
starting, and the first unfinished step's one question.
|
|
16
|
+
- **When the HQ app sends it** (they clicked "Explain HQ first"): the app
|
|
17
|
+
sends the text under "The explanation" at once, followed by the text under
|
|
18
|
+
"After the explanation, when the app sends it". Your next turn starts with
|
|
19
|
+
a note saying so. Do not send the explanation again.
|
|
20
|
+
|
|
21
|
+
## The explanation
|
|
22
|
+
|
|
23
|
+
**One shared brain for your company's AI.** Your clients, decisions, meeting notes, connected apps, keys and skills live in one place, and every AI that works for you reads from it and adds to it.
|
|
24
|
+
|
|
25
|
+
**It works with the agents you already use.** Claude Code, Codex or Cursor, opened in your HQ folder, already know your business. You can put agents in Slack so your team can ask them things where they already talk, and connect other agents from the HQ console under Agents. Messaging bots in this app is one more way in, not the main one.
|
|
26
|
+
|
|
27
|
+
**It's built for your team.** Invite someone and their AI knows the business on day one. Connected apps and keys can be shared with them without passing passwords around, and each company stays separate.
|
|
28
|
+
|
|
29
|
+
## After the explanation, when the app sends it
|
|
30
|
+
|
|
31
|
+
Ready to start the setup?
|
|
32
|
+
|
|
33
|
+
```hq-block
|
|
34
|
+
{"v":1,"blocks":[{"kind":"suggestions","items":["Let's start","How is this different from Claude?"]}]}
|
|
35
|
+
```
|
|
@@ -42,6 +42,9 @@ skills:
|
|
|
42
42
|
- name: standing-help
|
|
43
43
|
description: "How to behave once setup is finished: the always-there helper who does the thing rather than describing it."
|
|
44
44
|
doc: skills/standing-help.md
|
|
45
|
+
- name: explain-hq
|
|
46
|
+
description: "The fixed explanation of HQ for someone who asks for it at the start of setup. Sent word for word, by you or by the HQ app."
|
|
47
|
+
doc: skills/explain-hq.md
|
|
45
48
|
|
|
46
49
|
instructions: |
|
|
47
50
|
# Setup
|
|
@@ -256,28 +259,26 @@ instructions: |
|
|
|
256
259
|
|
|
257
260
|
## Explaining HQ at the start
|
|
258
261
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
(a Slack agent, an outside agent, inviting someone, sharing an app or a
|
|
280
|
-
key), walk them through it using `skills/agents-and-team.md`.
|
|
262
|
+
The explanation is fixed, and it lives in `skills/explain-hq.md`. The same
|
|
263
|
+
text goes out whether you send it or the HQ app does, so never write your
|
|
264
|
+
own version.
|
|
265
|
+
|
|
266
|
+
When they click "Explain HQ first", the HQ app sends the explanation for
|
|
267
|
+
you straight away, and your next turn starts with a note from the app
|
|
268
|
+
saying it went out. The question is then answered: write `Intro: explained`
|
|
269
|
+
in your progress note, do not explain HQ again, and carry on with the first
|
|
270
|
+
unfinished step's one question.
|
|
271
|
+
|
|
272
|
+
When they ask for the explanation any other way (in their own words, or by
|
|
273
|
+
clicking "Explain HQ first" with no note from the app at the start of your
|
|
274
|
+
turn), read `skills/explain-hq.md` and send the text under its "The
|
|
275
|
+
explanation" heading word for word. Then one line that you are starting,
|
|
276
|
+
and the first unfinished step's one question, in the same message.
|
|
277
|
+
|
|
278
|
+
Do not ask whether the explanation made sense, and do not offer a tour. If
|
|
279
|
+
they ask how to do one of those things (a Slack agent, an outside agent,
|
|
280
|
+
inviting someone, sharing an app or a key), walk them through it using
|
|
281
|
+
`skills/agents-and-team.md`.
|
|
281
282
|
|
|
282
283
|
## How you make them feel it
|
|
283
284
|
|
|
@@ -407,7 +408,8 @@ instructions: |
|
|
|
407
408
|
company", "Just for me". Put the answer you would recommend first.
|
|
408
409
|
- **You offered a choice or a yes/no:** "Want me to invite them now?" gets
|
|
409
410
|
"Yes, invite them", "Not yet".
|
|
410
|
-
- **The explain-or-jump question:** "Explain HQ first", "Jump straight in"
|
|
411
|
+
- **The explain-or-jump question:** "Explain HQ first", "Jump straight in",
|
|
412
|
+
in exactly those words. The HQ app answers "Explain HQ first" by itself.
|
|
411
413
|
- **You finished something and did not ask anything:** the one or two next
|
|
412
414
|
steps most people would take ("Connect my calls", "How do I add my team?").
|
|
413
415
|
|
package/dist/commands/lanes.js
CHANGED
|
@@ -79,6 +79,7 @@ export { MAX_LANE_DEPTH };
|
|
|
79
79
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
80
80
|
export const LANE_REMOTE_MIRROR_SYNC_FLAG = "lanes.remote-done-mirror";
|
|
81
81
|
const REMOTE_MIRROR_REFRESH_BATCH_SIZE = 100;
|
|
82
|
+
const REMOTE_LANE_NOT_FOUND_GRACE_MS = 15 * 60_000;
|
|
82
83
|
/** The lane → mesh seam. Defined in lib/lanes/mesh-events.ts; the watcher shares it. */
|
|
83
84
|
export { emitLaneDecisionEvents };
|
|
84
85
|
export { LANE_OUTPOST_SNAPSHOT_CACHE_FLAG };
|
|
@@ -1129,6 +1130,7 @@ function normalizedLaneListQuery(query) {
|
|
|
1129
1130
|
...(hierarchy === undefined ? {} : { hierarchy }),
|
|
1130
1131
|
...(query.snapshot ? { snapshot: true } : {}),
|
|
1131
1132
|
...(query.strictSnapshot ? { strictSnapshot: true } : {}),
|
|
1133
|
+
...(query.includeForeignHostRows ? { includeForeignHostRows: true } : {}),
|
|
1132
1134
|
};
|
|
1133
1135
|
}
|
|
1134
1136
|
async function readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOptions = {}) {
|
|
@@ -1191,7 +1193,7 @@ async function readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOpt
|
|
|
1191
1193
|
// Outpost's own store, so preserve those local identities for accounting.
|
|
1192
1194
|
const hasLocalHostIdentity = rowHost.kind === "local" &&
|
|
1193
1195
|
typeof rowHost.id === "string" && rowHost.id.trim() !== "";
|
|
1194
|
-
if (!belongsToOutpost && !hasLocalHostIdentity)
|
|
1196
|
+
if (!belongsToOutpost && !hasLocalHostIdentity && !query.includeForeignHostRows)
|
|
1195
1197
|
continue;
|
|
1196
1198
|
try {
|
|
1197
1199
|
lanes.push(parseLaneRecord(normalizeLaneRecordView(raw)));
|
|
@@ -1216,6 +1218,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
|
|
|
1216
1218
|
const result = {
|
|
1217
1219
|
updated: new Map(),
|
|
1218
1220
|
unverifiedRemote: new Map(),
|
|
1221
|
+
missingOnOwner: new Map(),
|
|
1219
1222
|
};
|
|
1220
1223
|
const host = reconcileHostFromCtx(ctx);
|
|
1221
1224
|
if (ctx.env.HQ_LANES_REMOTE_READ === "1" || parentLaneIds?.size === 0)
|
|
@@ -1285,6 +1288,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
|
|
|
1285
1288
|
laneIds: batch.map((lane) => lane.lane_id),
|
|
1286
1289
|
snapshot: true,
|
|
1287
1290
|
strictSnapshot: true,
|
|
1291
|
+
includeForeignHostRows: true,
|
|
1288
1292
|
});
|
|
1289
1293
|
}
|
|
1290
1294
|
catch (error) {
|
|
@@ -1306,6 +1310,8 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
|
|
|
1306
1310
|
!ownerIsHostedByOutpost ||
|
|
1307
1311
|
(owner.company?.uid ?? null) !== (mirror.company?.uid ?? null) ||
|
|
1308
1312
|
(owner.company?.slug ?? null) !== (mirror.company?.slug ?? null)) {
|
|
1313
|
+
if (matches.length === 0)
|
|
1314
|
+
result.missingOnOwner.set(mirror.lane_id, mirror);
|
|
1309
1315
|
const detail = matches.length === 0
|
|
1310
1316
|
? "owner did not return the requested lane"
|
|
1311
1317
|
: matches.length > 1
|
|
@@ -1329,6 +1335,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
|
|
|
1329
1335
|
}
|
|
1330
1336
|
const needsUpdate = before.state !== owner.state ||
|
|
1331
1337
|
before.queued_reason !== (owner.state === "queued" ? owner.queued_reason : undefined) ||
|
|
1338
|
+
JSON.stringify(before.queued_details) !== JSON.stringify(owner.state === "queued" ? owner.queued_details : undefined) ||
|
|
1332
1339
|
before.ended_at !== endedAt ||
|
|
1333
1340
|
before.envelope_summary !== envelopeSummary ||
|
|
1334
1341
|
(isTerminalLaneState(owner.state) && (JSON.stringify(before.last_envelope) !== JSON.stringify(owner.last_envelope) ||
|
|
@@ -1348,6 +1355,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
|
|
|
1348
1355
|
...current,
|
|
1349
1356
|
state: owner.state,
|
|
1350
1357
|
queued_reason: owner.state === "queued" ? owner.queued_reason : undefined,
|
|
1358
|
+
queued_details: owner.state === "queued" ? owner.queued_details : undefined,
|
|
1351
1359
|
ended_at: endedAt,
|
|
1352
1360
|
envelope_summary: envelopeSummary,
|
|
1353
1361
|
...(isTerminalLaneState(owner.state) ? {
|
|
@@ -2445,6 +2453,47 @@ function markDispatchFailure(hqRoot, laneId, details) {
|
|
|
2445
2453
|
};
|
|
2446
2454
|
});
|
|
2447
2455
|
}
|
|
2456
|
+
function retainQueuedOutpostAfterUnknownDelivery(hqRoot, laneId, outpostId, commandId) {
|
|
2457
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
2458
|
+
if (current.state !== "queued" ||
|
|
2459
|
+
current.host.kind !== "outpost" ||
|
|
2460
|
+
current.host.id !== outpostId)
|
|
2461
|
+
return current;
|
|
2462
|
+
return {
|
|
2463
|
+
...current,
|
|
2464
|
+
queued_details: {
|
|
2465
|
+
reason: "outpost_dispatch_unknown",
|
|
2466
|
+
outcome_unknown: true,
|
|
2467
|
+
command_id: commandId,
|
|
2468
|
+
},
|
|
2469
|
+
};
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
function markRemoteLaneLost(hqRoot, laneId, outpostId, at, writer, operatorReason, expectedVersion) {
|
|
2473
|
+
return retryUpdateLane(hqRoot, laneId, writer, (current) => {
|
|
2474
|
+
if (current.host.kind !== "outpost" ||
|
|
2475
|
+
current.host.id !== outpostId ||
|
|
2476
|
+
(expectedVersion !== undefined && current.version !== expectedVersion) ||
|
|
2477
|
+
isTerminalLaneState(current.state))
|
|
2478
|
+
return current;
|
|
2479
|
+
return {
|
|
2480
|
+
...current,
|
|
2481
|
+
state: "failed",
|
|
2482
|
+
queued_reason: undefined,
|
|
2483
|
+
queued_details: undefined,
|
|
2484
|
+
terminal_reason: "remote_lost",
|
|
2485
|
+
terminal_details: {
|
|
2486
|
+
outpost_id: outpostId,
|
|
2487
|
+
owner_error: "lane_not_found",
|
|
2488
|
+
...(operatorReason === undefined ? {} : { operator_reason: operatorReason }),
|
|
2489
|
+
},
|
|
2490
|
+
stop_history: [
|
|
2491
|
+
...(current.stop_history ?? []),
|
|
2492
|
+
{ at, reason: "remote_lost" },
|
|
2493
|
+
],
|
|
2494
|
+
};
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2448
2497
|
/**
|
|
2449
2498
|
* The CAS updater a pickup failure applies. A lane that is already terminal
|
|
2450
2499
|
* for any reason (a cancel that landed after the caller's last read, an
|
|
@@ -3528,6 +3577,22 @@ async function dispatchCreatedOutpostLane(ctx, input) {
|
|
|
3528
3577
|
exit_code: dispatched.exitCode,
|
|
3529
3578
|
});
|
|
3530
3579
|
}
|
|
3580
|
+
const commandId = dispatched.commandId?.trim();
|
|
3581
|
+
if (dispatched.done !== true || !commandId) {
|
|
3582
|
+
const details = {
|
|
3583
|
+
outpost_id: outpostId,
|
|
3584
|
+
lane_id: lane.lane_id,
|
|
3585
|
+
command_id: commandId || null,
|
|
3586
|
+
status: dispatched.status,
|
|
3587
|
+
done: dispatched.done ?? false,
|
|
3588
|
+
exit_code: dispatched.exitCode,
|
|
3589
|
+
outcome_unknown: true,
|
|
3590
|
+
};
|
|
3591
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not confirm pickup for lane ${lane.lane_id}; the queued lane will be reconciled against its owner`, {
|
|
3592
|
+
...details,
|
|
3593
|
+
state: retainQueuedOutpostAfterUnknownDelivery(hqRoot, lane.lane_id, outpostId, commandId || null).state,
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
3531
3596
|
const stdoutTrimmed = dispatched.stdout.trim();
|
|
3532
3597
|
if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
|
|
3533
3598
|
if (preserveQueuedOnFailure) {
|
|
@@ -3606,6 +3671,9 @@ async function dispatchCreatedOutpostLane(ctx, input) {
|
|
|
3606
3671
|
catch (err) {
|
|
3607
3672
|
if (preserveQueuedOnFailure)
|
|
3608
3673
|
throw err;
|
|
3674
|
+
if (isLanesError(err) && err.error === "outpost_delivery_unconfirmed" && err.details.outcome_unknown === true) {
|
|
3675
|
+
throw err;
|
|
3676
|
+
}
|
|
3609
3677
|
if (isLanesError(err) &&
|
|
3610
3678
|
err.error === DISPATCH_TERMINAL_REASON &&
|
|
3611
3679
|
readLane(hqRoot, lane.lane_id).state === "failed") {
|
|
@@ -4551,7 +4619,7 @@ async function runList(ctx, opts, fastRouteFlags) {
|
|
|
4551
4619
|
}
|
|
4552
4620
|
const remoteRead = ctx.env[REMOTE_LANE_READ_ENV] === "1";
|
|
4553
4621
|
const remoteRefresh = remoteRead
|
|
4554
|
-
? { updated: new Map(), unverifiedRemote: new Map() }
|
|
4622
|
+
? { updated: new Map(), unverifiedRemote: new Map(), missingOnOwner: new Map() }
|
|
4555
4623
|
: await refreshRemoteLaneMirrors(ctx, hqRoot, laneSource, new Set(selectedLanes
|
|
4556
4624
|
.filter((lane) => !isTerminalLaneState(lane.state))
|
|
4557
4625
|
.map((lane) => lane.lane_id)));
|
|
@@ -7035,11 +7103,21 @@ async function readOutpostTerminalLane(ctx, laneId, outpostId, stopCommandId, wa
|
|
|
7035
7103
|
commandId,
|
|
7036
7104
|
};
|
|
7037
7105
|
}
|
|
7106
|
+
function isDefiniteOutpostLaneNotFound(dispatched, laneId, ownerError) {
|
|
7107
|
+
return dispatched.done === true &&
|
|
7108
|
+
Boolean(dispatched.commandId?.trim()) &&
|
|
7109
|
+
dispatched.status === "Failed" &&
|
|
7110
|
+
dispatched.exitCode !== null &&
|
|
7111
|
+
dispatched.exitCode !== 0 &&
|
|
7112
|
+
ownerError?.error === "lane_not_found" &&
|
|
7113
|
+
ownerError.details.lane_id === laneId;
|
|
7114
|
+
}
|
|
7038
7115
|
async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
7039
7116
|
if (lane.host.kind !== "outpost") {
|
|
7040
7117
|
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
|
|
7041
7118
|
}
|
|
7042
7119
|
const outpostId = lane.host.id;
|
|
7120
|
+
const hqRoot = ctx.hqRoot();
|
|
7043
7121
|
const waitMs = outpostSingleLaneWaitMs(ctx.env);
|
|
7044
7122
|
let dispatched;
|
|
7045
7123
|
try {
|
|
@@ -7063,7 +7141,36 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
|
7063
7141
|
required_option: "--reason",
|
|
7064
7142
|
});
|
|
7065
7143
|
}
|
|
7144
|
+
const commandId = dispatched.commandId?.trim();
|
|
7066
7145
|
const ownerError = ownerErrorFromOutput(dispatched.stdout);
|
|
7146
|
+
if (isDefiniteOutpostLaneNotFound(dispatched, lane.lane_id, ownerError)) {
|
|
7147
|
+
const at = ctx.now().toISOString();
|
|
7148
|
+
const updated = markRemoteLaneLost(ctx.hqRoot(), lane.lane_id, outpostId, at, "command", reason);
|
|
7149
|
+
if (updated.terminal_reason !== "remote_lost") {
|
|
7150
|
+
throw new LanesError("already_terminal", `Lane ${lane.lane_id} became terminal before its missing Outpost record could be marked remote_lost`, { lane_id: lane.lane_id, state: updated.state });
|
|
7151
|
+
}
|
|
7152
|
+
const cancelled = cancelPendingQuestionsForLane(hqRoot, lane.lane_id, at, "remote_lost");
|
|
7153
|
+
await emitLaneTerminalStateSoft(ctx, updated);
|
|
7154
|
+
if (!quiet)
|
|
7155
|
+
printJson({
|
|
7156
|
+
ok: true,
|
|
7157
|
+
action: "stop",
|
|
7158
|
+
reason: "remote_lost",
|
|
7159
|
+
lane: updated,
|
|
7160
|
+
terminal: true,
|
|
7161
|
+
resumable: false,
|
|
7162
|
+
cancelled_questions: cancelled.map((question) => question.question_id),
|
|
7163
|
+
outpost_id: outpostId,
|
|
7164
|
+
outpost_command_id: commandId,
|
|
7165
|
+
outpost_result: {
|
|
7166
|
+
state: "failed",
|
|
7167
|
+
reason: "remote_lost",
|
|
7168
|
+
status: dispatched.status,
|
|
7169
|
+
exit_code: dispatched.exitCode,
|
|
7170
|
+
},
|
|
7171
|
+
});
|
|
7172
|
+
return;
|
|
7173
|
+
}
|
|
7067
7174
|
if (ownerError) {
|
|
7068
7175
|
throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the stop command: ${ownerError.message}`, {
|
|
7069
7176
|
lane_id: lane.lane_id,
|
|
@@ -7075,7 +7182,6 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
|
7075
7182
|
owner_error: ownerError,
|
|
7076
7183
|
});
|
|
7077
7184
|
}
|
|
7078
|
-
const commandId = dispatched.commandId?.trim();
|
|
7079
7185
|
if (dispatched.done !== true || !commandId) {
|
|
7080
7186
|
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a completed stop command for lane ${lane.lane_id}`, {
|
|
7081
7187
|
lane_id: lane.lane_id,
|
|
@@ -7559,8 +7665,42 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
|
|
|
7559
7665
|
const fromEnv = ctx.env.HQ_ROOT?.trim();
|
|
7560
7666
|
const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
|
|
7561
7667
|
const mirrorRefresh = opts.dryRun
|
|
7562
|
-
? {
|
|
7668
|
+
? {
|
|
7669
|
+
updated: new Map(),
|
|
7670
|
+
unverifiedRemote: new Map(),
|
|
7671
|
+
missingOnOwner: new Map(),
|
|
7672
|
+
}
|
|
7563
7673
|
: await refreshRemoteLaneMirrors(ctx, hqRoot, loadAllLanes(hqRoot));
|
|
7674
|
+
const remoteLost = [];
|
|
7675
|
+
const remoteLostErrors = [];
|
|
7676
|
+
if (!opts.dryRun && mirrorRefresh.missingOnOwner.size > 0) {
|
|
7677
|
+
const at = ctx.now().toISOString();
|
|
7678
|
+
const nowMs = Date.parse(at);
|
|
7679
|
+
for (const [laneId, mirror] of mirrorRefresh.missingOnOwner) {
|
|
7680
|
+
const createdAtMs = Date.parse(mirror.created_at);
|
|
7681
|
+
if (!Number.isFinite(createdAtMs) ||
|
|
7682
|
+
nowMs - createdAtMs < REMOTE_LANE_NOT_FOUND_GRACE_MS)
|
|
7683
|
+
continue;
|
|
7684
|
+
try {
|
|
7685
|
+
const updated = markRemoteLaneLost(hqRoot, laneId, mirror.host.id, at, "reconcile", undefined, mirror.version);
|
|
7686
|
+
if (updated.terminal_reason !== "remote_lost") {
|
|
7687
|
+
if (updated.version !== mirror.version) {
|
|
7688
|
+
mirrorRefresh.unverifiedRemote.set(laneId, "mirror changed after the owner snapshot");
|
|
7689
|
+
}
|
|
7690
|
+
continue;
|
|
7691
|
+
}
|
|
7692
|
+
remoteLost.push({ id: laneId, reason: "remote_lost" });
|
|
7693
|
+
cancelPendingQuestionsForLane(hqRoot, laneId, at, "remote_lost");
|
|
7694
|
+
await emitLaneTerminalStateSoft(ctx, updated);
|
|
7695
|
+
}
|
|
7696
|
+
catch (err) {
|
|
7697
|
+
remoteLostErrors.push({
|
|
7698
|
+
laneId,
|
|
7699
|
+
message: err instanceof Error ? err.message : String(err),
|
|
7700
|
+
});
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7703
|
+
}
|
|
7564
7704
|
const summary = await runReconcilePass(hqRoot, {
|
|
7565
7705
|
now: ctx.now,
|
|
7566
7706
|
host: reconcileHostFromCtx(ctx),
|
|
@@ -7676,10 +7816,22 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
|
|
|
7676
7816
|
}
|
|
7677
7817
|
},
|
|
7678
7818
|
});
|
|
7819
|
+
for (const failure of remoteLostErrors) {
|
|
7820
|
+
summary.errors.push({
|
|
7821
|
+
code: "remote_lane_terminalize_failed",
|
|
7822
|
+
message: `Could not mark remote lane ${failure.laneId} lost: ${failure.message}`,
|
|
7823
|
+
});
|
|
7824
|
+
summary.ok = false;
|
|
7825
|
+
}
|
|
7826
|
+
const terminalizedRemoteIds = new Set(remoteLost.map(({ id }) => id));
|
|
7679
7827
|
printJson({
|
|
7680
7828
|
...summary,
|
|
7829
|
+
...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
|
|
7681
7830
|
...(mirrorRefresh.unverifiedRemote.size > 0
|
|
7682
|
-
? {
|
|
7831
|
+
? {
|
|
7832
|
+
unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()]
|
|
7833
|
+
.filter((id) => !terminalizedRemoteIds.has(id)),
|
|
7834
|
+
}
|
|
7683
7835
|
: {}),
|
|
7684
7836
|
});
|
|
7685
7837
|
if (!summary.ok) {
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -52,7 +52,7 @@ import * as path from "node:path";
|
|
|
52
52
|
import { fullJitterDelayMs } from "../mesh/live/backoff.js";
|
|
53
53
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
54
54
|
import { BotApiError } from "./api.js";
|
|
55
|
-
import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, SETUP_BOT_WORKER_ID } from "./config.js";
|
|
55
|
+
import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, resolveBotMemoryDir, SETUP_BOT_WORKER_ID } from "./config.js";
|
|
56
56
|
import { ensureBotSessionMeta } from "./company-bind.js";
|
|
57
57
|
import { readBotCredsIdentity } from "./creds.js";
|
|
58
58
|
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
@@ -61,6 +61,7 @@ import { containsNoReplySentinel, ProgressPoster, } from "./progress.js";
|
|
|
61
61
|
import { splitBody } from "./split.js";
|
|
62
62
|
import { createBotLogger } from "./log.js";
|
|
63
63
|
import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
|
|
64
|
+
import { loadSetupExplainer, patchSetupIntroState, readSetupIntroState, readSetupProgressIntro, replyOffersExplain, SETUP_EXPLAINED_NOTE, setupExplainDue, } from "./setup-explain.js";
|
|
64
65
|
import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
|
|
65
66
|
import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
|
|
66
67
|
import { botAttachmentsDir, dmSessionScope } from "./paths.js";
|
|
@@ -296,6 +297,26 @@ export async function runBot(deps) {
|
|
|
296
297
|
const turnCwd = config.hqRoot;
|
|
297
298
|
// HQ's setup bot is a guided conversation: no "working on it" filler between steps.
|
|
298
299
|
const isSetupBot = config.workerId === SETUP_WORKER_ID;
|
|
300
|
+
/**
|
|
301
|
+
* Setup bot, owner DM: remember whether the bot's latest reply asked the
|
|
302
|
+
* explain-or-jump question, so a click on "Explain HQ first" can be answered
|
|
303
|
+
* at once (setup-explain.ts). Never lets a bookkeeping error touch the turn.
|
|
304
|
+
*/
|
|
305
|
+
const noteSetupReply = (reply) => {
|
|
306
|
+
if (!isSetupBot)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
309
|
+
const offered = replyOffersExplain(reply);
|
|
310
|
+
const current = readSetupIntroState(dir);
|
|
311
|
+
if (offered && !current.offeredAt)
|
|
312
|
+
patchSetupIntroState(dir, { offeredAt: now().toISOString() });
|
|
313
|
+
else if (!offered && current.offeredAt)
|
|
314
|
+
patchSetupIntroState(dir, { offeredAt: undefined });
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
299
320
|
// Scheduled runs are reported in the owner's own zone: "every weekday at 9am"
|
|
300
321
|
// was set up here, so "due 13:00 UTC" would read as the wrong time.
|
|
301
322
|
const timeZone = machineTimeZone();
|
|
@@ -1105,6 +1126,7 @@ export async function runBot(deps) {
|
|
|
1105
1126
|
return;
|
|
1106
1127
|
}
|
|
1107
1128
|
clearInflight(dir, KICKOFF_MESSAGE_ID);
|
|
1129
|
+
noteSetupReply(outcome.reply);
|
|
1108
1130
|
if (readBotConfig(dir)?.kickoffPendingSignIn)
|
|
1109
1131
|
patchBotConfig(dir, { kickoffPendingSignIn: false });
|
|
1110
1132
|
const st = readStatus();
|
|
@@ -1323,6 +1345,54 @@ export async function runBot(deps) {
|
|
|
1323
1345
|
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
|
|
1324
1346
|
}
|
|
1325
1347
|
};
|
|
1348
|
+
/**
|
|
1349
|
+
* The instant answer to "Explain HQ first" (setup-explain.ts). Returns true
|
|
1350
|
+
* when it answered the message (sent, marked processed, acked); false sends
|
|
1351
|
+
* the message down the normal model path, so nothing here can leave a
|
|
1352
|
+
* message unanswered.
|
|
1353
|
+
*/
|
|
1354
|
+
const sendSetupExplanation = async (item, text, attachments) => {
|
|
1355
|
+
const id = item.messageId;
|
|
1356
|
+
let reply;
|
|
1357
|
+
try {
|
|
1358
|
+
const due = setupExplainDue({
|
|
1359
|
+
text,
|
|
1360
|
+
attachments,
|
|
1361
|
+
state: readSetupIntroState(dir),
|
|
1362
|
+
progress: readSetupProgressIntro(resolveBotMemoryDir(config)),
|
|
1363
|
+
});
|
|
1364
|
+
if (!due)
|
|
1365
|
+
return false;
|
|
1366
|
+
const explainer = loadSetupExplainer(config.hqRoot, config.workerDir);
|
|
1367
|
+
if (!explainer) {
|
|
1368
|
+
log("warn", `no usable explain-hq.md in ${config.workerDir}; answering ${id} with a model turn`);
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
reply = explainer.reply;
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
log("warn", `setup explanation check failed (${err instanceof Error ? err.message : String(err)}); answering ${id} with a model turn`);
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
try {
|
|
1378
|
+
await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, reply, item.rootEventId, "human");
|
|
1379
|
+
}
|
|
1380
|
+
catch (err) {
|
|
1381
|
+
log("warn", `setup explanation for ${id} could not be sent (${err instanceof Error ? err.message : String(err)}); answering with a model turn`);
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
markProcessed(dir, id, now().toISOString());
|
|
1385
|
+
try {
|
|
1386
|
+
patchSetupIntroState(dir, { explainedAt: now().toISOString(), notePending: true, offeredAt: undefined });
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
|
|
1390
|
+
}
|
|
1391
|
+
await api.ackInbox(config.agentUid, id);
|
|
1392
|
+
setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
|
|
1393
|
+
log("info", `answered ${id} with the setup explanation (no model turn)`);
|
|
1394
|
+
return true;
|
|
1395
|
+
};
|
|
1326
1396
|
const handleItem = async (item) => {
|
|
1327
1397
|
const id = item.messageId;
|
|
1328
1398
|
const inboxState = readInboxState(dir);
|
|
@@ -1381,6 +1451,11 @@ export async function runBot(deps) {
|
|
|
1381
1451
|
await api.ackInbox(config.agentUid, id);
|
|
1382
1452
|
return;
|
|
1383
1453
|
}
|
|
1454
|
+
// Setup bot: "Explain HQ first" clicked while that question is pending is
|
|
1455
|
+
// answered with the fixed explanation, with no model turn.
|
|
1456
|
+
const ownerDm = target.kind === "dm" && target.peerUid === config.ownerUid;
|
|
1457
|
+
if (isSetupBot && ownerDm && (await sendSetupExplanation(item, messageText, attachmentRefs.length)))
|
|
1458
|
+
return;
|
|
1384
1459
|
const attempt = (attempts.get(id) ?? 0) + 1;
|
|
1385
1460
|
attempts.set(id, attempt);
|
|
1386
1461
|
const reactionId = item.eventId ?? id;
|
|
@@ -1441,7 +1516,10 @@ export async function runBot(deps) {
|
|
|
1441
1516
|
text = text ? `${text}\n\n${block}` : block;
|
|
1442
1517
|
}
|
|
1443
1518
|
const sessionScope = target.kind === "room" ? target.sessionScope : dmSessionScope(target.peerUid, config.ownerUid);
|
|
1444
|
-
|
|
1519
|
+
// The turn after the app sent the explanation: tell the model it went out.
|
|
1520
|
+
const setupNote = isSetupBot && ownerDm && readSetupIntroState(dir).notePending === true;
|
|
1521
|
+
const ownerText = setupNote ? `${SETUP_EXPLAINED_NOTE}\n\n${text}` : text;
|
|
1522
|
+
const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
|
|
1445
1523
|
`[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
|
|
1446
1524
|
const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
|
|
1447
1525
|
const { send, sendFinal, saved } = trackingSend(markerTarget, item.rootEventId);
|
|
@@ -1463,6 +1541,18 @@ export async function runBot(deps) {
|
|
|
1463
1541
|
? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
|
|
1464
1542
|
: {}),
|
|
1465
1543
|
});
|
|
1544
|
+
// The model saw the note once its turn ran. A turn that never started, or
|
|
1545
|
+
// one held for a coding-tool sign-in, runs again and keeps the note for it.
|
|
1546
|
+
if (setupNote && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
|
|
1547
|
+
try {
|
|
1548
|
+
patchSetupIntroState(dir, { notePending: false });
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (outcome.ok && ownerDm)
|
|
1555
|
+
noteSetupReply(outcome.reply);
|
|
1466
1556
|
if (outcome.ok) {
|
|
1467
1557
|
// A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
|
|
1468
1558
|
if (outcome.silent) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup bot's instant "Explain HQ first" answer.
|
|
3
|
+
*
|
|
4
|
+
* The setup bot's first question is whether to explain HQ or jump straight in,
|
|
5
|
+
* with "Explain HQ first" as a suggested reply. The explanation is fixed text,
|
|
6
|
+
* so a click on that button does not need a model turn: the runner sends the
|
|
7
|
+
* text from the setup worker's `skills/explain-hq.md` at once, and the bot's
|
|
8
|
+
* next turn starts with a note saying the explanation already went out.
|
|
9
|
+
*
|
|
10
|
+
* The text has one home, the worker file. The model reads the same file when
|
|
11
|
+
* someone asks for the explanation in their own words, so the two paths never
|
|
12
|
+
* drift. Anything unexpected (file missing or malformed, the question not
|
|
13
|
+
* pending, any other message) falls back to the normal model turn.
|
|
14
|
+
*/
|
|
15
|
+
/** The suggested reply that triggers the instant answer. Exact match only. */
|
|
16
|
+
export declare const EXPLAIN_HQ_SUGGESTION = "Explain HQ first";
|
|
17
|
+
/** Where the explanation lives, relative to the setup worker folder. */
|
|
18
|
+
export declare const EXPLAIN_HQ_FILE: string;
|
|
19
|
+
/** The setup bot's progress note in its memory folder (setup worker.yaml). */
|
|
20
|
+
export declare const SETUP_PROGRESS_FILE = "setup-progress.md";
|
|
21
|
+
/** Runner-owned state, in the bot's own folder (never the model's memory). */
|
|
22
|
+
export declare const SETUP_INTRO_STATE_FILE = "setup-intro.json";
|
|
23
|
+
/**
|
|
24
|
+
* Put in front of the next message from the person, so the model knows the
|
|
25
|
+
* explanation went out without it. It never reaches the person.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SETUP_EXPLAINED_NOTE: string;
|
|
28
|
+
export interface SetupExplainer {
|
|
29
|
+
/** The explanation, word for word (shared with the model path). */
|
|
30
|
+
explanation: string;
|
|
31
|
+
/** What the app adds after it: a line offering to continue, and suggestions. */
|
|
32
|
+
closing: string;
|
|
33
|
+
/** The whole message the app sends. */
|
|
34
|
+
reply: string;
|
|
35
|
+
}
|
|
36
|
+
/** Parse the worker file. Null when it is not the shape the app can send. */
|
|
37
|
+
export declare function parseSetupExplainer(body: string): SetupExplainer | null;
|
|
38
|
+
/** Read the explanation from the setup worker folder the bot runs. */
|
|
39
|
+
export declare function loadSetupExplainer(hqRoot: string, workerDir: string): SetupExplainer | null;
|
|
40
|
+
export type SetupProgressIntro = "asked" | "answered" | "finished" | null;
|
|
41
|
+
/** What the model's progress note says about the opening question. */
|
|
42
|
+
export declare function readSetupProgressIntro(memoryDir: string): SetupProgressIntro;
|
|
43
|
+
export interface SetupIntroState {
|
|
44
|
+
v: 1;
|
|
45
|
+
/** The bot's latest owner-DM reply asked the explain-or-jump question. */
|
|
46
|
+
offeredAt?: string;
|
|
47
|
+
/** The app sent the explanation (never sent twice). */
|
|
48
|
+
explainedAt?: string;
|
|
49
|
+
/** The model has not yet been told that the explanation went out. */
|
|
50
|
+
notePending?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function readSetupIntroState(dir: string): SetupIntroState;
|
|
53
|
+
export declare function patchSetupIntroState(dir: string, patch: Partial<Omit<SetupIntroState, "v">>): SetupIntroState;
|
|
54
|
+
/** A bot reply that asks the explain-or-jump question (the words or the button). */
|
|
55
|
+
export declare function replyOffersExplain(reply: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The instant answer is due only when ALL of these hold: the message is exactly
|
|
58
|
+
* the button text with nothing attached, the question is pending (the bot's
|
|
59
|
+
* last reply offered it, or its note says it asked), and the explanation has
|
|
60
|
+
* not gone out yet (neither by the app nor, per the note, by the model).
|
|
61
|
+
*/
|
|
62
|
+
export declare function setupExplainDue(input: {
|
|
63
|
+
text: string;
|
|
64
|
+
attachments: number;
|
|
65
|
+
state: SetupIntroState;
|
|
66
|
+
progress: SetupProgressIntro;
|
|
67
|
+
}): boolean;
|
|
68
|
+
//# sourceMappingURL=setup-explain.d.ts.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup bot's instant "Explain HQ first" answer.
|
|
3
|
+
*
|
|
4
|
+
* The setup bot's first question is whether to explain HQ or jump straight in,
|
|
5
|
+
* with "Explain HQ first" as a suggested reply. The explanation is fixed text,
|
|
6
|
+
* so a click on that button does not need a model turn: the runner sends the
|
|
7
|
+
* text from the setup worker's `skills/explain-hq.md` at once, and the bot's
|
|
8
|
+
* next turn starts with a note saying the explanation already went out.
|
|
9
|
+
*
|
|
10
|
+
* The text has one home, the worker file. The model reads the same file when
|
|
11
|
+
* someone asks for the explanation in their own words, so the two paths never
|
|
12
|
+
* drift. Anything unexpected (file missing or malformed, the question not
|
|
13
|
+
* pending, any other message) falls back to the normal model turn.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
/** The suggested reply that triggers the instant answer. Exact match only. */
|
|
18
|
+
export const EXPLAIN_HQ_SUGGESTION = "Explain HQ first";
|
|
19
|
+
/** Where the explanation lives, relative to the setup worker folder. */
|
|
20
|
+
export const EXPLAIN_HQ_FILE = path.join("skills", "explain-hq.md");
|
|
21
|
+
/** The setup bot's progress note in its memory folder (setup worker.yaml). */
|
|
22
|
+
export const SETUP_PROGRESS_FILE = "setup-progress.md";
|
|
23
|
+
/** Runner-owned state, in the bot's own folder (never the model's memory). */
|
|
24
|
+
export const SETUP_INTRO_STATE_FILE = "setup-intro.json";
|
|
25
|
+
const EXPLANATION_HEADING = /^##[ \t]+The explanation[ \t]*$/m;
|
|
26
|
+
const CLOSING_HEADING = /^##[ \t]+After the explanation, when the app sends it[ \t]*$/m;
|
|
27
|
+
/**
|
|
28
|
+
* Put in front of the next message from the person, so the model knows the
|
|
29
|
+
* explanation went out without it. It never reaches the person.
|
|
30
|
+
*/
|
|
31
|
+
export const SETUP_EXPLAINED_NOTE = `[Note from the HQ app: the person clicked "${EXPLAIN_HQ_SUGGESTION}", and the app already sent them the explanation ` +
|
|
32
|
+
`from skills/explain-hq.md word for word, ending by asking whether to start the setup. The explain-or-jump question is answered. ` +
|
|
33
|
+
"Write `Intro: explained` in your progress note, do not explain HQ again, and carry on with the first unfinished setup step. " +
|
|
34
|
+
"Their reply to that message follows.]";
|
|
35
|
+
/** The text between a heading and the next `## ` heading (or the end). */
|
|
36
|
+
function sectionAfter(body, heading) {
|
|
37
|
+
const match = heading.exec(body);
|
|
38
|
+
if (!match)
|
|
39
|
+
return null;
|
|
40
|
+
const rest = body.slice(match.index + match[0].length);
|
|
41
|
+
const next = /^##[ \t]/m.exec(rest);
|
|
42
|
+
return (next ? rest.slice(0, next.index) : rest).trim();
|
|
43
|
+
}
|
|
44
|
+
/** True when the closing carries exactly one well-formed suggestions block. */
|
|
45
|
+
function hasSuggestionsBlock(closing) {
|
|
46
|
+
const fence = /^```hq-block\n(.+)\n```$/m.exec(closing);
|
|
47
|
+
if (!fence)
|
|
48
|
+
return false;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(fence[1]);
|
|
51
|
+
const block = parsed.blocks?.[0];
|
|
52
|
+
return (parsed.v === 1 &&
|
|
53
|
+
block?.kind === "suggestions" &&
|
|
54
|
+
Array.isArray(block.items) &&
|
|
55
|
+
block.items.length >= 2 &&
|
|
56
|
+
block.items.every((item) => typeof item === "string" && item.trim().length > 0));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Parse the worker file. Null when it is not the shape the app can send. */
|
|
63
|
+
export function parseSetupExplainer(body) {
|
|
64
|
+
const text = body.replace(/\r\n/g, "\n");
|
|
65
|
+
const explanation = sectionAfter(text, EXPLANATION_HEADING);
|
|
66
|
+
const closing = sectionAfter(text, CLOSING_HEADING);
|
|
67
|
+
if (!explanation || !closing)
|
|
68
|
+
return null;
|
|
69
|
+
// The closing must end with the block (nothing after it) so the app shows the buttons.
|
|
70
|
+
if (!closing.endsWith("```") || !hasSuggestionsBlock(closing))
|
|
71
|
+
return null;
|
|
72
|
+
return { explanation, closing, reply: `${explanation}\n\n${closing}` };
|
|
73
|
+
}
|
|
74
|
+
/** Read the explanation from the setup worker folder the bot runs. */
|
|
75
|
+
export function loadSetupExplainer(hqRoot, workerDir) {
|
|
76
|
+
try {
|
|
77
|
+
return parseSetupExplainer(fs.readFileSync(path.resolve(hqRoot, workerDir, EXPLAIN_HQ_FILE), "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** What the model's progress note says about the opening question. */
|
|
84
|
+
export function readSetupProgressIntro(memoryDir) {
|
|
85
|
+
let note;
|
|
86
|
+
try {
|
|
87
|
+
note = fs.readFileSync(path.join(memoryDir, SETUP_PROGRESS_FILE), "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (/^Status:[ \t]*finished/im.test(note))
|
|
93
|
+
return "finished";
|
|
94
|
+
if (/^Intro:[ \t]*(explained|skipped)\b/im.test(note))
|
|
95
|
+
return "answered";
|
|
96
|
+
if (/^Intro:[ \t]*asked\b/im.test(note))
|
|
97
|
+
return "asked";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
export function readSetupIntroState(dir) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, SETUP_INTRO_STATE_FILE), "utf8"));
|
|
103
|
+
return parsed && parsed.v === 1 ? parsed : { v: 1 };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { v: 1 };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function patchSetupIntroState(dir, patch) {
|
|
110
|
+
const next = { ...readSetupIntroState(dir), ...patch, v: 1 };
|
|
111
|
+
for (const key of Object.keys(next)) {
|
|
112
|
+
if (next[key] === undefined)
|
|
113
|
+
delete next[key];
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
const dest = path.join(dir, SETUP_INTRO_STATE_FILE);
|
|
117
|
+
const tmp = `${dest}.tmp.${process.pid}`;
|
|
118
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
119
|
+
fs.renameSync(tmp, dest);
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
122
|
+
/** A bot reply that asks the explain-or-jump question (the words or the button). */
|
|
123
|
+
export function replyOffersExplain(reply) {
|
|
124
|
+
return reply.includes(`"${EXPLAIN_HQ_SUGGESTION}"`) || /explain HQ to you/i.test(reply);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The instant answer is due only when ALL of these hold: the message is exactly
|
|
128
|
+
* the button text with nothing attached, the question is pending (the bot's
|
|
129
|
+
* last reply offered it, or its note says it asked), and the explanation has
|
|
130
|
+
* not gone out yet (neither by the app nor, per the note, by the model).
|
|
131
|
+
*/
|
|
132
|
+
export function setupExplainDue(input) {
|
|
133
|
+
if (input.text !== EXPLAIN_HQ_SUGGESTION || input.attachments > 0)
|
|
134
|
+
return false;
|
|
135
|
+
if (input.state.explainedAt)
|
|
136
|
+
return false;
|
|
137
|
+
if (input.progress === "answered" || input.progress === "finished")
|
|
138
|
+
return false;
|
|
139
|
+
return Boolean(input.state.offeredAt) || input.progress === "asked";
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=setup-explain.js.map
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -532,26 +532,44 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
532
532
|
const details = raw.queued_details;
|
|
533
533
|
if (details === null || typeof details !== "object" || Array.isArray(details) ||
|
|
534
534
|
typeof details.reason !== "string") {
|
|
535
|
-
throw new LaneSchemaError("queued_details must describe a
|
|
535
|
+
throw new LaneSchemaError("queued_details must describe a supported queue condition", {
|
|
536
536
|
path: filePath,
|
|
537
537
|
field: "queued_details",
|
|
538
538
|
});
|
|
539
539
|
}
|
|
540
540
|
const detailsRecord = details;
|
|
541
|
-
if (detailsRecord.reason
|
|
542
|
-
(detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
541
|
+
if (detailsRecord.reason === "worker_profile_changed") {
|
|
542
|
+
if ((detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
|
|
543
|
+
typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
|
|
544
|
+
typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
|
|
545
|
+
throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
|
|
546
|
+
path: filePath,
|
|
547
|
+
field: "queued_details",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (raw.queued_reason !== "worker_profile_changed") {
|
|
551
|
+
throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
|
|
552
|
+
path: filePath,
|
|
553
|
+
field: "queued_details",
|
|
554
|
+
queued_reason: raw.queued_reason,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else if (detailsRecord.reason === "outpost_dispatch_unknown") {
|
|
559
|
+
if (raw.state !== "queued" ||
|
|
560
|
+
detailsRecord.outcome_unknown !== true ||
|
|
561
|
+
(detailsRecord.command_id !== null &&
|
|
562
|
+
(typeof detailsRecord.command_id !== "string" || detailsRecord.command_id.trim() === ""))) {
|
|
563
|
+
throw new LaneSchemaError("queued_details must describe an unknown Outpost dispatch on a queued lane", {
|
|
564
|
+
path: filePath,
|
|
565
|
+
field: "queued_details",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
549
568
|
}
|
|
550
|
-
|
|
551
|
-
throw new LaneSchemaError("queued_details
|
|
569
|
+
else {
|
|
570
|
+
throw new LaneSchemaError("queued_details must describe a supported queue condition", {
|
|
552
571
|
path: filePath,
|
|
553
572
|
field: "queued_details",
|
|
554
|
-
queued_reason: raw.queued_reason,
|
|
555
573
|
});
|
|
556
574
|
}
|
|
557
575
|
}
|
|
@@ -175,12 +175,16 @@ export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
|
|
|
175
175
|
export type LaneProvider = (typeof LANE_PROVIDERS)[number];
|
|
176
176
|
export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing", "account_retry", "worker_profile_changed"];
|
|
177
177
|
export type QueuedReason = (typeof QUEUED_REASONS)[number];
|
|
178
|
-
export
|
|
178
|
+
export type QueuedDetails = {
|
|
179
179
|
reason: "worker_profile_changed";
|
|
180
180
|
expected_hash: string | null;
|
|
181
181
|
actual_hash: string;
|
|
182
182
|
recovery_command: string;
|
|
183
|
-
}
|
|
183
|
+
} | {
|
|
184
|
+
reason: "outpost_dispatch_unknown";
|
|
185
|
+
outcome_unknown: true;
|
|
186
|
+
command_id: string | null;
|
|
187
|
+
};
|
|
184
188
|
export declare function isQueuedReason(value: unknown): value is QueuedReason;
|
|
185
189
|
export declare const HOST_KINDS: readonly ["local", "outpost"];
|
|
186
190
|
export type HostKind = (typeof HOST_KINDS)[number];
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -231,7 +231,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
231
231
|
last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
|
|
232
232
|
last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
233
233
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
234
|
-
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
234
|
+
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
235
235
|
quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
236
236
|
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
237
237
|
};
|