hilos-agent 0.11.3 → 0.11.4
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/README.md +12 -1
- package/package.json +1 -1
- package/src/mcp-loopback.mjs +25 -8
- package/src/mcp.mjs +78 -3
- package/src/reply-bridge.mjs +80 -20
- package/src/run.mjs +32 -4
package/README.md
CHANGED
|
@@ -392,12 +392,23 @@ npx hilos-agent@latest hooks print
|
|
|
392
392
|
`hilos-agent` is running. Ambient messages, older thread history, and agent
|
|
393
393
|
replies do not wake it. Current workspace roles are checked at pickup: guests,
|
|
394
394
|
removed people, and unknown authors remain advisory and cannot start code.
|
|
395
|
+
- Every initial mention job is also signed for its exact agent-token row and
|
|
396
|
+
answer room after final server revalidation. The daemon keeps that proof on a
|
|
397
|
+
job-local MCP client, so concurrent rooms cannot exchange project context. A
|
|
398
|
+
guest-visible room may be explicitly allowed to run code, but its task still
|
|
399
|
+
cannot pull workspace memory, Docs, Tasks, sibling conversations, or external
|
|
400
|
+
context into the answer.
|
|
395
401
|
- The resumed turn receives bounded thread, room, and member context. Codex also
|
|
396
402
|
gets a random loopback-only Hilos MCP URL for that turn, so it can search any
|
|
397
403
|
room its linked owner can access when that owner minted the current key,
|
|
398
404
|
without receiving the bearer token. Regenerate an older key from Connect to
|
|
399
405
|
enable that inheritance. Admin-issued keys and other people's private rooms
|
|
400
|
-
stay outside the context.
|
|
406
|
+
stay outside the context. Hilos signs the turn's room into a short-lived
|
|
407
|
+
claim tied to that exact agent-token row; the loopback fixes the claim in an
|
|
408
|
+
upstream header that the coding child cannot replace. A missing, malformed,
|
|
409
|
+
cross-token, or conflicting room claim is refused, and a guest-visible turn
|
|
410
|
+
cannot use workspace-wide tools to pull private sibling context into its
|
|
411
|
+
answer.
|
|
401
412
|
- The room's normal execution gate still applies; a chat-only guest room cannot
|
|
402
413
|
resume local code.
|
|
403
414
|
- Steps are coalesced into ~2s batches to keep traffic light.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/mcp-loopback.mjs
CHANGED
|
@@ -28,6 +28,22 @@ const FORWARDED_REQUEST_HEADERS = [
|
|
|
28
28
|
];
|
|
29
29
|
const MAX_HEADER_LENGTH = 4096;
|
|
30
30
|
|
|
31
|
+
/** A v2 claim is the only format the server may use as ambient authority. This
|
|
32
|
+
* local shape check is not verification — the upstream server still checks the
|
|
33
|
+
* HMAC and exact token id — but it keeps 48-hour v1 state from advertising a
|
|
34
|
+
* loopback whose every call would be rejected after the rollout. */
|
|
35
|
+
export function isV2AmbientBindingClaim(claim) {
|
|
36
|
+
if (typeof claim !== "string" || !claim || claim.length > MAX_HEADER_LENGTH) return false;
|
|
37
|
+
const [payload, signature, extra] = claim.split(".");
|
|
38
|
+
if (!payload || !signature || extra) return false;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
41
|
+
return parsed?.v === 2 && typeof parsed?.tokenId === "string" && Boolean(parsed.tokenId);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
function readBody(request) {
|
|
32
48
|
return new Promise((resolve, reject) => {
|
|
33
49
|
let size = 0;
|
|
@@ -52,21 +68,19 @@ function readBody(request) {
|
|
|
52
68
|
* @param {{
|
|
53
69
|
* url?: string,
|
|
54
70
|
* token?: string,
|
|
55
|
-
*
|
|
71
|
+
* bindingClaim?: string,
|
|
56
72
|
* fetchImpl?: typeof fetch,
|
|
57
73
|
* }} [options]
|
|
58
74
|
* @returns {Promise<{url: string, close: () => Promise<void>} | null>}
|
|
59
75
|
*/
|
|
60
|
-
export async function startHilosMcpLoopback({ url, token,
|
|
61
|
-
|
|
76
|
+
export async function startHilosMcpLoopback({ url, token, bindingClaim = "", fetchImpl = fetch } = {}) {
|
|
77
|
+
const ambientBinding = typeof bindingClaim === "string" ? bindingClaim.trim() : "";
|
|
78
|
+
// A continuation without its server-signed binding must not get a workspace
|
|
79
|
+
// MCP bridge whose guest-sensitive reads have no authoritative room context.
|
|
80
|
+
if (!url || !token || !isV2AmbientBindingClaim(ambientBinding)) return null;
|
|
62
81
|
const nonce = randomBytes(24).toString("hex");
|
|
63
82
|
const path = `/mcp/${nonce}`;
|
|
64
83
|
const upstream = new URL(url);
|
|
65
|
-
// Preserve the room that caused this turn as MCP's effective context. This
|
|
66
|
-
// does not widen or narrow token access, but it keeps guest-room memory gates
|
|
67
|
-
// and other context-sensitive server policy active even when the model calls
|
|
68
|
-
// a workspace-scoped tool without a channelId argument.
|
|
69
|
-
if (channelId) upstream.searchParams.set("channelId", channelId);
|
|
70
84
|
const upstreamRequests = new Set();
|
|
71
85
|
|
|
72
86
|
const server = createServer(async (request, response) => {
|
|
@@ -83,6 +97,9 @@ export async function startHilosMcpLoopback({ url, token, channelId = "", fetchI
|
|
|
83
97
|
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
84
98
|
const headers = {
|
|
85
99
|
authorization: `Bearer ${token}`,
|
|
100
|
+
// 1215 — fixed daemon-owned authority. This header is intentionally not
|
|
101
|
+
// in FORWARDED_REQUEST_HEADERS, so a coding child cannot replace it.
|
|
102
|
+
"x-hilos-ambient-binding": ambientBinding,
|
|
86
103
|
// This short-lived proxy is part of the persistent daemon's return
|
|
87
104
|
// path. Marking it on-demand would temporarily relabel a live daemon
|
|
88
105
|
// and could suppress the offline/wakeup behavior after the turn.
|
package/src/mcp.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
|
|
|
11
11
|
const DEFAULT_TIMEOUT_MS = 35_000;
|
|
12
12
|
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
13
13
|
const RETRY_DELAY_MS = 250;
|
|
14
|
+
const MAX_AMBIENT_BINDING_LENGTH = 4_096;
|
|
14
15
|
|
|
15
16
|
function installedVersion() {
|
|
16
17
|
try {
|
|
@@ -51,6 +52,13 @@ export class McpAuthStopError extends McpRequestError {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
export class McpAmbientBindingError extends McpRequestError {
|
|
56
|
+
constructor(message, details = {}) {
|
|
57
|
+
super(message, { ...details, ambientBindingRejected: true });
|
|
58
|
+
this.name = "McpAmbientBindingError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
function headerValue(value) {
|
|
55
63
|
const text = String(value);
|
|
56
64
|
return /^[\x20-\x7e]+$/.test(text)
|
|
@@ -233,6 +241,9 @@ export function makeClient({
|
|
|
233
241
|
authorization: `Bearer ${token}`,
|
|
234
242
|
"x-hilos-connection-mode": "daemon",
|
|
235
243
|
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
244
|
+
...(options.ambientBinding
|
|
245
|
+
? { "x-hilos-ambient-binding": options.ambientBinding }
|
|
246
|
+
: {}),
|
|
236
247
|
...(modern
|
|
237
248
|
? {
|
|
238
249
|
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
@@ -256,9 +267,23 @@ export function makeClient({
|
|
|
256
267
|
let res;
|
|
257
268
|
let json = null;
|
|
258
269
|
let transportError = null;
|
|
270
|
+
let requestUrl = url;
|
|
271
|
+
if (options.ambientChannelId) {
|
|
272
|
+
try {
|
|
273
|
+
const scopedUrl = new URL(url);
|
|
274
|
+
scopedUrl.searchParams.set("channelId", options.ambientChannelId);
|
|
275
|
+
requestUrl = scopedUrl.toString();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
bounded.cleanup();
|
|
278
|
+
throw new McpRequestError("hilos has an invalid MCP URL", {
|
|
279
|
+
cause: error,
|
|
280
|
+
malformedResponse: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
259
284
|
try {
|
|
260
285
|
try {
|
|
261
|
-
res = await fetchImpl(
|
|
286
|
+
res = await fetchImpl(requestUrl, {
|
|
262
287
|
method: "POST",
|
|
263
288
|
headers,
|
|
264
289
|
body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params: requestParams }),
|
|
@@ -347,7 +372,7 @@ export function makeClient({
|
|
|
347
372
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
348
373
|
try {
|
|
349
374
|
const result = await request("server/discover", undefined, {
|
|
350
|
-
|
|
375
|
+
...options,
|
|
351
376
|
timeoutMs: Math.min(timeoutMs, DISCOVERY_TIMEOUT_MS),
|
|
352
377
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
353
378
|
});
|
|
@@ -411,6 +436,49 @@ export function makeClient({
|
|
|
411
436
|
}
|
|
412
437
|
}
|
|
413
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Pin every call made by one queued daemon job to the room claim returned
|
|
441
|
+
* with that job. This is a closure, not mutable client state: concurrent
|
|
442
|
+
* work from different rooms can never overwrite or inherit another job's
|
|
443
|
+
* ambient privacy boundary.
|
|
444
|
+
*/
|
|
445
|
+
function bindTool(bindingClaim, channelId) {
|
|
446
|
+
const bindingClaimAbsent = bindingClaim === undefined;
|
|
447
|
+
const ambientBinding =
|
|
448
|
+
typeof bindingClaim === "string" ? bindingClaim.trim() : "";
|
|
449
|
+
const ambientChannelId = typeof channelId === "string" ? channelId.trim() : "";
|
|
450
|
+
return async (name, args = {}, options = {}) => {
|
|
451
|
+
if (
|
|
452
|
+
!ambientChannelId ||
|
|
453
|
+
(!bindingClaimAbsent &&
|
|
454
|
+
(!ambientBinding || ambientBinding.length > MAX_AMBIENT_BINDING_LENGTH))
|
|
455
|
+
) {
|
|
456
|
+
throw new McpAmbientBindingError("hilos returned an invalid ambient room binding", {
|
|
457
|
+
malformedResponse: true,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
return await tool(name, args, {
|
|
462
|
+
...options,
|
|
463
|
+
ambientChannelId,
|
|
464
|
+
...(ambientBinding ? { ambientBinding } : {}),
|
|
465
|
+
});
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// A scoped 401 without the terminal-token bit means the bearer token
|
|
468
|
+
// still exists but this queued job's room proof was rejected (expired,
|
|
469
|
+
// rotated, or mismatched). Surface a distinct failure so the daemon can
|
|
470
|
+
// restart from its durable cursor instead of acknowledging lost work.
|
|
471
|
+
if (error?.status === 401 && error?.stop !== true) {
|
|
472
|
+
throw new McpAmbientBindingError(error.message, {
|
|
473
|
+
...error,
|
|
474
|
+
cause: error,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
414
482
|
// Full tool objects, schemas included. Never turn discovery errors into an
|
|
415
483
|
// empty registry: that would silently downgrade a current daemon until its
|
|
416
484
|
// next restart. One bounded retry handles a transient read failure.
|
|
@@ -440,5 +508,12 @@ export function makeClient({
|
|
|
440
508
|
return (await listTools(options)).map((tool) => tool.name);
|
|
441
509
|
}
|
|
442
510
|
|
|
443
|
-
return {
|
|
511
|
+
return {
|
|
512
|
+
rpc,
|
|
513
|
+
tool,
|
|
514
|
+
bindTool,
|
|
515
|
+
listTools,
|
|
516
|
+
listToolNames,
|
|
517
|
+
stopSignal: stopController.signal,
|
|
518
|
+
};
|
|
444
519
|
}
|
package/src/reply-bridge.mjs
CHANGED
|
@@ -17,7 +17,10 @@ import { makeStreamParser } from "./agent-events.mjs";
|
|
|
17
17
|
import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
|
|
18
18
|
import { buildResumeArgs } from "./resume.mjs";
|
|
19
19
|
import { commandArgv } from "./argv.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isV2AmbientBindingClaim,
|
|
22
|
+
startHilosMcpLoopback,
|
|
23
|
+
} from "./mcp-loopback.mjs";
|
|
21
24
|
import {
|
|
22
25
|
HOOK_STATE_DIR,
|
|
23
26
|
hookConnectionKey,
|
|
@@ -255,11 +258,15 @@ function matchingSessions(sessions, { connectionKey = "" } = {}) {
|
|
|
255
258
|
);
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
/** Persist a
|
|
259
|
-
*
|
|
260
|
-
* failure stays fail-closed. */
|
|
261
|
-
function
|
|
262
|
-
if (
|
|
261
|
+
/** Persist a freshly server-verified v2 claim, optionally claiming a tokenless
|
|
262
|
+
* hook session for this credential at the same time. Re-read after the write so
|
|
263
|
+
* an IO failure or concurrent replacement stays fail-closed. */
|
|
264
|
+
function persistVerifiedBinding(binding, nextClaim, stateDir, connectionKey = "") {
|
|
265
|
+
if (
|
|
266
|
+
!binding?.sessionId ||
|
|
267
|
+
!binding?.bindingClaim ||
|
|
268
|
+
!isV2AmbientBindingClaim(nextClaim)
|
|
269
|
+
) return false;
|
|
263
270
|
const state = readState(binding.sessionId, stateDir);
|
|
264
271
|
if (!state || (state.connectionKey && state.connectionKey !== connectionKey)) return false;
|
|
265
272
|
const current = (Array.isArray(state.bindings) ? state.bindings : []).find(
|
|
@@ -270,9 +277,27 @@ function claimVerifiedSession(binding, connectionKey, stateDir) {
|
|
|
270
277
|
item?.agentId === binding.agentId,
|
|
271
278
|
);
|
|
272
279
|
if (!current) return false;
|
|
273
|
-
|
|
280
|
+
current.bindingClaim = nextClaim;
|
|
281
|
+
if (connectionKey) state.connectionKey = connectionKey;
|
|
274
282
|
writeState(binding.sessionId, state, stateDir);
|
|
275
|
-
|
|
283
|
+
const saved = readState(binding.sessionId, stateDir);
|
|
284
|
+
const savedBinding = saved?.bindings?.find(
|
|
285
|
+
(item) =>
|
|
286
|
+
item?.threadRootId === binding.threadRootId &&
|
|
287
|
+
item?.anchorMessageId === binding.anchorMessageId &&
|
|
288
|
+
item?.agentId === binding.agentId,
|
|
289
|
+
);
|
|
290
|
+
return savedBinding?.bindingClaim === nextClaim &&
|
|
291
|
+
(!connectionKey || saved?.connectionKey === connectionKey);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function bindingVerificationArgs(binding) {
|
|
295
|
+
return {
|
|
296
|
+
claim: binding.bindingClaim,
|
|
297
|
+
channelId: binding.channelId,
|
|
298
|
+
threadRootId: binding.threadRootId,
|
|
299
|
+
messageId: binding.anchorMessageId,
|
|
300
|
+
};
|
|
276
301
|
}
|
|
277
302
|
|
|
278
303
|
async function verifyBindingAuthority(binding, authority, tool, stateDir) {
|
|
@@ -284,14 +309,14 @@ async function verifyBindingAuthority(binding, authority, tool, stateDir) {
|
|
|
284
309
|
!authority.agentId ||
|
|
285
310
|
binding.agentId !== authority.agentId
|
|
286
311
|
) return false;
|
|
287
|
-
const verified = await tool("verify_session_binding",
|
|
288
|
-
claim: binding.bindingClaim,
|
|
289
|
-
channelId: binding.channelId,
|
|
290
|
-
threadRootId: binding.threadRootId,
|
|
291
|
-
messageId: binding.anchorMessageId,
|
|
292
|
-
});
|
|
312
|
+
const verified = await tool("verify_session_binding", bindingVerificationArgs(binding));
|
|
293
313
|
return verified?.valid === true &&
|
|
294
|
-
|
|
314
|
+
persistVerifiedBinding(
|
|
315
|
+
binding,
|
|
316
|
+
verified.bindingClaim,
|
|
317
|
+
stateDir,
|
|
318
|
+
authority.connectionKey,
|
|
319
|
+
);
|
|
295
320
|
}
|
|
296
321
|
|
|
297
322
|
function clearPendingDelivery(sessionId, deliveryId, stateDir = HOOK_STATE_DIR) {
|
|
@@ -675,12 +700,21 @@ function vendorLabel(vendor) {
|
|
|
675
700
|
* now?: () => number,
|
|
676
701
|
* log?: { log: (...args: any[]) => void },
|
|
677
702
|
* signal?: AbortSignal,
|
|
703
|
+
* startLoopback?: typeof startHilosMcpLoopback,
|
|
678
704
|
* }} deps
|
|
679
705
|
*/
|
|
680
706
|
export async function handleReplyBridgeJob(
|
|
681
707
|
{ binding, replies: queuedReplies, context },
|
|
682
708
|
cfg,
|
|
683
|
-
{
|
|
709
|
+
{
|
|
710
|
+
tool,
|
|
711
|
+
run = runCli,
|
|
712
|
+
stateDir = HOOK_STATE_DIR,
|
|
713
|
+
now = Date.now,
|
|
714
|
+
log = console,
|
|
715
|
+
signal,
|
|
716
|
+
startLoopback = startHilosMcpLoopback,
|
|
717
|
+
},
|
|
684
718
|
) {
|
|
685
719
|
// A second batch may have queued while the first continuation was running.
|
|
686
720
|
// Re-read the durable processed set at execution time so the overlap is
|
|
@@ -692,7 +726,13 @@ export async function handleReplyBridgeJob(
|
|
|
692
726
|
const processed = new Set(
|
|
693
727
|
Array.isArray(currentBinding?.processedReplyIds) ? currentBinding.processedReplyIds : [],
|
|
694
728
|
);
|
|
695
|
-
const liveBinding =
|
|
729
|
+
const liveBinding = {
|
|
730
|
+
...binding,
|
|
731
|
+
...(currentBinding ?? {}),
|
|
732
|
+
// scanReplyBridge may have just claimed a tokenless hook session by writing
|
|
733
|
+
// the top-level key; carry that fresh authority into this already-built job.
|
|
734
|
+
connectionKey: currentState?.connectionKey || binding.connectionKey || "",
|
|
735
|
+
};
|
|
696
736
|
const queuedIds = new Set(
|
|
697
737
|
(queuedReplies || []).map((reply) => reply?.id).filter((id) => id && !processed.has(id)),
|
|
698
738
|
);
|
|
@@ -733,11 +773,31 @@ export async function handleReplyBridgeJob(
|
|
|
733
773
|
return { status: "skipped" };
|
|
734
774
|
}
|
|
735
775
|
|
|
736
|
-
|
|
737
|
-
|
|
776
|
+
// Verify and rotate the claim against the current server immediately before
|
|
777
|
+
// exposing MCP. This upgrades persisted v1 state, prevents a new daemon from
|
|
778
|
+
// trusting an older server, and keeps active bindings ahead of the 48-hour
|
|
779
|
+
// claim expiry that successful continuations also renew locally.
|
|
780
|
+
const ambientVerification =
|
|
781
|
+
liveBinding.vendor === "codex" && liveBinding.bindingClaim
|
|
782
|
+
? await tool("verify_session_binding", bindingVerificationArgs(liveBinding))
|
|
783
|
+
.catch(() => null)
|
|
784
|
+
: null;
|
|
785
|
+
const refreshedAmbientClaim =
|
|
786
|
+
ambientVerification?.valid === true &&
|
|
787
|
+
isV2AmbientBindingClaim(ambientVerification.bindingClaim) &&
|
|
788
|
+
persistVerifiedBinding(
|
|
789
|
+
liveBinding,
|
|
790
|
+
ambientVerification.bindingClaim,
|
|
791
|
+
stateDir,
|
|
792
|
+
liveBinding.connectionKey,
|
|
793
|
+
)
|
|
794
|
+
? ambientVerification.bindingClaim
|
|
795
|
+
: "";
|
|
796
|
+
const loopback = refreshedAmbientClaim
|
|
797
|
+
? await startLoopback({
|
|
738
798
|
url: cfg?.url,
|
|
739
799
|
token: cfg?.token,
|
|
740
|
-
|
|
800
|
+
bindingClaim: refreshedAmbientClaim,
|
|
741
801
|
}).catch(() => null)
|
|
742
802
|
: null;
|
|
743
803
|
const prompt = bridgePrompt(replies, context, { hilosMcp: Boolean(loopback) });
|
package/src/run.mjs
CHANGED
|
@@ -104,6 +104,9 @@ export async function run(
|
|
|
104
104
|
// Match the typed client error by its stable cross-realm name. This also
|
|
105
105
|
// works for embedding hosts and test doubles that do not share a constructor.
|
|
106
106
|
const isAuthStop = (error) => error?.name === "McpAuthStopError";
|
|
107
|
+
const isAmbientBindingRejection = (error) =>
|
|
108
|
+
error?.name === "McpAmbientBindingError" ||
|
|
109
|
+
error?.ambientBindingRejected === true;
|
|
107
110
|
const reportAuthStop = (error) => {
|
|
108
111
|
if (!isAuthStop(error)) return false;
|
|
109
112
|
emitStopping();
|
|
@@ -113,7 +116,7 @@ export async function run(
|
|
|
113
116
|
const client = makeClient({ url: cfg.url, token: cfg.token });
|
|
114
117
|
// Keep injected/older test clients compatible; the real makeClient always
|
|
115
118
|
// provides this lifecycle signal and terminal auth still latches through it.
|
|
116
|
-
const { tool, listTools, listToolNames } = client;
|
|
119
|
+
const { tool, bindTool, listTools, listToolNames } = client;
|
|
117
120
|
const stopSignal = client.stopSignal ?? new AbortController().signal;
|
|
118
121
|
const abortFromAuthStop = () => runController.abort(stopSignal.reason);
|
|
119
122
|
stopSignal.addEventListener("abort", abortFromAuthStop, { once: true });
|
|
@@ -145,6 +148,7 @@ export async function run(
|
|
|
145
148
|
let iterateClaimRecoveryStore = null;
|
|
146
149
|
let queue = null;
|
|
147
150
|
let wake = null;
|
|
151
|
+
let fatalJobError = null;
|
|
148
152
|
let reconcileClaimsOnce = async () => true;
|
|
149
153
|
const withinShutdownReconcileBound = async (promise) => {
|
|
150
154
|
let timer;
|
|
@@ -522,6 +526,10 @@ export async function run(
|
|
|
522
526
|
|
|
523
527
|
async function safeHandle(message, channelId, jobSignal) {
|
|
524
528
|
emit({ type: "task-start", channelId, messageId: message.id, text: (message.body || "").slice(0, 200) });
|
|
529
|
+
const taskTool =
|
|
530
|
+
typeof bindTool === "function"
|
|
531
|
+
? bindTool(message.bindingClaim, channelId)
|
|
532
|
+
: tool;
|
|
525
533
|
// 0779 — pull this mention's images to disk HERE, as the job starts, not
|
|
526
534
|
// when it was enqueued. Downloading at enqueue meant a burst of image
|
|
527
535
|
// mentions held every job's bytes (up to 4 x 10MB each) on the user's disk
|
|
@@ -548,7 +556,7 @@ export async function run(
|
|
|
548
556
|
if (stopNoticed) return;
|
|
549
557
|
stopNoticed = true;
|
|
550
558
|
const who = typeof by === "string" && by.trim() ? by.trim() : null;
|
|
551
|
-
void
|
|
559
|
+
void taskTool("post_message", {
|
|
552
560
|
channelId,
|
|
553
561
|
parentId: message.parentId ?? null,
|
|
554
562
|
body: who ? `Stopped by ${who} from the room.` : "Stopped from the room.",
|
|
@@ -559,7 +567,14 @@ export async function run(
|
|
|
559
567
|
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
560
568
|
// liveCfg so a job uses the latest model/permission/codingCmd at run time.
|
|
561
569
|
const result = await handler(
|
|
562
|
-
{
|
|
570
|
+
{
|
|
571
|
+
message: withImages,
|
|
572
|
+
channelId,
|
|
573
|
+
tool: taskTool,
|
|
574
|
+
me,
|
|
575
|
+
caps,
|
|
576
|
+
iterateClaimRecoveryStore,
|
|
577
|
+
},
|
|
563
578
|
liveCfg,
|
|
564
579
|
undefined,
|
|
565
580
|
{ signal: jobSignal, onStopRequested },
|
|
@@ -568,7 +583,18 @@ export async function run(
|
|
|
568
583
|
} catch (e) {
|
|
569
584
|
emit({ type: "task-error", channelId, error: e?.message || String(e) });
|
|
570
585
|
log.error(`handler error: ${e.message}`);
|
|
571
|
-
|
|
586
|
+
if (isAmbientBindingRejection(e)) {
|
|
587
|
+
// The job's room proof is no longer usable. Close intake synchronously
|
|
588
|
+
// before runJob unwinds so createQueue skips afterJob (and therefore
|
|
589
|
+
// cannot persist this mention's cursor), then restart from the durable
|
|
590
|
+
// cursor. Posting with the same rejected proof would only hide the
|
|
591
|
+
// failure behind a second 401.
|
|
592
|
+
fatalJobError ??= e;
|
|
593
|
+
runController.abort(e);
|
|
594
|
+
void queue.shutdown("ambient room binding rejected");
|
|
595
|
+
throw e;
|
|
596
|
+
}
|
|
597
|
+
await taskTool("post_message", {
|
|
572
598
|
channelId,
|
|
573
599
|
body: `Hit an error working on that: ${e.message}`,
|
|
574
600
|
}).catch(() => {});
|
|
@@ -877,6 +903,8 @@ export async function run(
|
|
|
877
903
|
await interruptibleSleep(pollAgainMs != null ? Math.min(base, pollAgainMs) : base);
|
|
878
904
|
} while (!runSignal.aborted);
|
|
879
905
|
|
|
906
|
+
if (fatalJobError) throw fatalJobError;
|
|
907
|
+
|
|
880
908
|
// A terminal response may first surface inside a concurrently running
|
|
881
909
|
// handler while the poll loop is sleeping. The client latch aborts the run
|
|
882
910
|
// immediately; preserve the original typed error after the loop has fenced
|