@agentconnect.md/daemon 1.0.0-rc.323 → 1.0.0-rc.324

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.
@@ -3,10 +3,10 @@ import { n as require_ms, t as require_src$3 } from "./src-DDtlVutj.js";
3
3
  import { n as init_esm_shims, t as __dirname } from "./esm-shims-DxGcm4Hw.js";
4
4
  import { t as DAEMON_VERSION } from "./version-DGaRXjBK.js";
5
5
  import { C as _coercedNumber, T as NEVER, a as array, d as literal, f as looseObject, i as any, m as object, o as boolean, p as number$1, t as ZodNumber, v as string, x as url$4 } from "./schemas-CQkO9hC5.js";
6
- import { a as RELAY_DAEMON_WS_PATH, c as SHARED_CONFIG_ACTION_ID, d as encode, f as MAX_FRAME_BYTES, g as ZodIssueCode, h as RESERVED_MCP_SERVER_NAME, i as RELAY_DAEMON_SUBPROTOCOL, l as buildEnvelope, n as gitRepoLabel, o as buildRelayDaemonFrame, r as normalizeGitUrl, s as decodeRelayDaemonFrame, u as decodeEnvelope } from "./dist-D8I6ZfGL.js";
7
- import { a as agentChildEnv, c as persistRelays, i as selectAgent, n as findAgentFiles, o as loadConfig, r as loadAgents, s as persistDaemonId } from "./load-agents-C2pFahVp.js";
6
+ import { _ as ZodIssueCode, a as RELAY_DAEMON_WS_PATH, c as SHARED_CONFIG_ACTION_ID, d as encode, f as MAX_FRAME_BYTES, g as RESERVED_MCP_SERVER_NAME, i as RELAY_DAEMON_SUBPROTOCOL, l as buildEnvelope, m as HookReport, n as gitRepoLabel, o as buildRelayDaemonFrame, r as normalizeGitUrl, s as decodeRelayDaemonFrame, u as decodeEnvelope } from "./dist-D97dgR66.js";
7
+ import { a as agentChildEnv, c as persistRelays, i as selectAgent, n as findAgentFiles, o as loadConfig, r as loadAgents, s as persistDaemonId } from "./load-agents-CuXy_Ei1.js";
8
8
  import { l as resolveRoot, o as mcpSocketPath, u as statePath } from "./paths-DEcUO8lC.js";
9
- import { C as cachedRuntimeVersions, S as cachedRuntimeNames, _ as resolveInMemoryDir, a as prepareWorkspace, b as MEMORY_TOOL_NAMES, c as sessionGitEnv, d as memoryKindOf, f as memoryProviderFor, g as listMemory, h as MemoryTooLargeError, i as prefetchWorkspace, l as simpleGit, m as MemoryPathError, n as turnFailureReason, o as initGitInjection, p as MemoryConflictError, r as installedRuntimes, s as probeGitVersion, t as AcpHost, u as createMemoryProvider, v as writeMemoryFile, w as resolveRuntimes, x as toolsForIntegrations, y as ALL_TOOL_NAMES } from "./acp-host-BEjpql6l.js";
9
+ import { C as cachedRuntimeNames, S as toolsForIntegrations, T as resolveRuntimes, _ as resolveInMemoryDir, a as prepareWorkspace, b as GITHUB_REVIEW_TOOLS, c as sessionGitEnv, d as memoryKindOf, f as memoryProviderFor, g as listMemory, h as MemoryTooLargeError, i as prefetchWorkspace, l as simpleGit, m as MemoryPathError, n as turnFailureReason, o as initGitInjection, p as MemoryConflictError, r as installedRuntimes, s as probeGitVersion, t as AcpHost, u as createMemoryProvider, v as writeMemoryFile, w as cachedRuntimeVersions, x as MEMORY_TOOL_NAMES, y as ALL_TOOL_NAMES } from "./acp-host-DKkoWiWe.js";
10
10
  import { i as GitCredentialCache, n as gitcredSocketPath, r as writeGitcredShim, t as GitCredServer } from "./gitcred-server-Bu-Svl7L.js";
11
11
  import { B as ToolListChangedNotificationSchema, D as ListPromptsResultSchema, E as ListChangedOptionsBaseSchema, F as PromptListChangedNotificationSchema, G as isZ4Schema, H as isJSONRPCRequest, I as ReadResourceResultSchema, J as encodeFrame, K as safeParse, L as ResourceListChangedNotificationSchema, M as ListToolsResultSchema, O as ListResourceTemplatesResultSchema, P as McpError, R as SUPPORTED_PROTOCOL_VERSIONS, S as InitializeResultSchema, T as LATEST_PROTOCOL_VERSION, U as isJSONRPCResultResponse, V as isInitializedNotification, W as getObjectShape, _ as ElicitResultSchema, a as AjvJsonSchemaValidator, b as GetPromptResultSchema, c as mergeCapabilities, d as CompleteResultSchema, f as CreateMessageRequestSchema, g as ElicitRequestSchema, h as CreateTaskResultSchema, i as assertToolsCallTaskCapability, k as ListResourcesResultSchema, m as CreateMessageResultWithToolsSchema, n as serializeMessage, o as require_fast_deep_equal, p as CreateMessageResultSchema, q as decodeFrames, r as assertClientRequestTaskCapability, s as Protocol, t as ReadBuffer, u as CallToolResultSchema, v as EmptyResultSchema, w as JSONRPCMessageSchema, y as ErrorCode } from "./stdio-DnS8WcXi.js";
12
12
  import { createRequire } from "node:module";
@@ -4229,6 +4229,7 @@ var CpClient = class {
4229
4229
  correlator;
4230
4230
  stopped = false;
4231
4231
  fatal = false;
4232
+ hookReportAckV1 = false;
4232
4233
  attempt = 0;
4233
4234
  reconnectTimer;
4234
4235
  lastAuthedEpoch = 0;
@@ -4306,12 +4307,14 @@ var CpClient = class {
4306
4307
  this.deps.onWebAppUrl?.(ok.webAppUrl);
4307
4308
  this.deps.onOrgSlug?.(ok.orgSlug);
4308
4309
  this.state = "REGISTERING";
4309
- const snap = (await this.request("register", {
4310
+ const regOk = await this.request("register", {
4310
4311
  host: this.deps.host,
4311
4312
  capabilities: this.deps.capabilities(),
4312
4313
  maxAgents: this.deps.maxAgents,
4313
4314
  localState: this.deps.localState()
4314
- })).payload;
4315
+ });
4316
+ const snap = regOk.payload;
4317
+ this.hookReportAckV1 = regOk.payload.serverFeatures?.includes("hook-report-ack-v1") ?? false;
4315
4318
  this.routingEpoch = snap.routingEpoch;
4316
4319
  this.deps.configApply.applyReconcileSnapshot(snap);
4317
4320
  this.state = "READY";
@@ -4380,15 +4383,45 @@ var CpClient = class {
4380
4383
  this.transport?.send(encode(buildEnvelope("cron/report", report)));
4381
4384
  }
4382
4385
  /**
4383
- * Report a hook fire's turn outcome (D→C `hook/report` EVT, fire-and-forget) —
4384
- * the completion half of the HookRun two-stage lifecycle (the relay's
4385
- * `rc/run-report` opened the row). A drop is absorbed by the CP's
4386
- * HookRunReaper (`failed(orphaned)`, still overwritable by a late report).
4387
- * No-op unless READY/DRAINING.
4386
+ * Durably converge a hook turn outcome (D→C `hook/report` REQ). The daemon
4387
+ * retains its metadata-only outbox row until the CP replies after persistence
4388
+ * and projection convergence, so a long CP outage delays GC instead of losing
4389
+ * a terminal result.
4388
4390
  */
4389
- emitHookReport(report) {
4390
- if (this.state !== "READY" && this.state !== "DRAINING") return;
4391
- this.transport?.send(encode(buildEnvelope("hook/report", report)));
4391
+ async emitHookReport(report) {
4392
+ this.requireReady("hook/report");
4393
+ if (!this.hookReportAckV1) {
4394
+ this.transport.send(encode(buildEnvelope("hook/report", report)));
4395
+ return "legacy-sent";
4396
+ }
4397
+ const rep = await this.request("hook/report", report);
4398
+ if (rep.type !== "ack" || rep.payload.ok !== true) throw new WireError("INTERNAL", `expected hook/report ack, got ${rep.type}`, false);
4399
+ return "acknowledged";
4400
+ }
4401
+ /** Durable start barrier for an accepted GitHub hook turn. Formal review is
4402
+ * not exposed until this correlated request succeeds. */
4403
+ async startHook(payload) {
4404
+ this.requireReady("hook/start");
4405
+ const rep = await this.request("hook/start", payload);
4406
+ if (rep.type !== "hook/start/ok") throw new WireError("INTERNAL", `expected hook/start/ok, got ${rep.type}`, false);
4407
+ return rep.payload;
4408
+ }
4409
+ /** One-attempt, action-time formal-review purpose token. */
4410
+ async authorizeGithubReview(payload) {
4411
+ this.requireReady("github/review-authorize");
4412
+ const rep = await this.request("github/review-authorize", payload);
4413
+ if (rep.type !== "github/review-authorized") throw new WireError("INTERNAL", `expected github/review-authorized, got ${rep.type}`, false);
4414
+ return rep.payload;
4415
+ }
4416
+ /** Immediate body-free outcome; HookReport repeats it for lost-reply recovery. */
4417
+ async reportGithubReviewResult(payload) {
4418
+ this.requireReady("github/review-result");
4419
+ const rep = await this.request("github/review-result", payload);
4420
+ if (rep.type !== "github/review-result/ok") throw new WireError("INTERNAL", `expected github/review-result/ok, got ${rep.type}`, false);
4421
+ return rep.payload;
4422
+ }
4423
+ requireReady(op) {
4424
+ if (this.state !== "READY" && this.state !== "DRAINING" || !this.transport) throw new WireError("INTERNAL", `control plane unreachable for ${op} (client ${this.state})`, true);
4392
4425
  }
4393
4426
  request(type, payload) {
4394
4427
  const frame = buildEnvelope(type, payload);
@@ -6416,6 +6449,11 @@ var LocalStore = class {
6416
6449
  msg TEXT NOT NULL,
6417
6450
  integrationId TEXT,
6418
6451
  callMeta TEXT,
6452
+ hookContext TEXT,
6453
+ posterPublishState TEXT,
6454
+ terminalReport TEXT,
6455
+ legacyReportConnection TEXT,
6456
+ completedAt INTEGER,
6419
6457
  isQueueCmd INTEGER,
6420
6458
  loopGuardCounted INTEGER NOT NULL DEFAULT 0,
6421
6459
  enqueuedAt TEXT NOT NULL
@@ -6481,6 +6519,7 @@ var LocalStore = class {
6481
6519
  this.migrateInboxLoopGuardCounted();
6482
6520
  this.migrateTranscriptToolBody();
6483
6521
  this.migrateTranscriptRecipient();
6522
+ this.migrateInboxHookContext();
6484
6523
  }
6485
6524
  /** Add the `transcript.recipient` column (the agent a row was delivered to) to a
6486
6525
  * pre-existing DB. Legacy rows have NULL recipient, so they surface in a session view
@@ -6488,6 +6527,16 @@ var LocalStore = class {
6488
6527
  migrateTranscriptRecipient() {
6489
6528
  if (!this.db.prepare("PRAGMA table_info(transcript)").all().some((c) => c.name === "recipient")) this.db.exec("ALTER TABLE transcript ADD COLUMN recipient TEXT");
6490
6529
  }
6530
+ /** R1 hook turns must retain their trusted dispatch fence and the poster's
6531
+ * single-attempt state across restart. Upgrade existing local stores in place. */
6532
+ migrateInboxHookContext() {
6533
+ const cols = this.db.prepare("PRAGMA table_info(inbox)").all();
6534
+ if (!cols.some((c) => c.name === "hookContext")) this.db.exec("ALTER TABLE inbox ADD COLUMN hookContext TEXT");
6535
+ if (!cols.some((c) => c.name === "posterPublishState")) this.db.exec("ALTER TABLE inbox ADD COLUMN posterPublishState TEXT");
6536
+ if (!cols.some((c) => c.name === "terminalReport")) this.db.exec("ALTER TABLE inbox ADD COLUMN terminalReport TEXT");
6537
+ if (!cols.some((c) => c.name === "legacyReportConnection")) this.db.exec("ALTER TABLE inbox ADD COLUMN legacyReportConnection TEXT");
6538
+ if (!cols.some((c) => c.name === "completedAt")) this.db.exec("ALTER TABLE inbox ADD COLUMN completedAt INTEGER");
6539
+ }
6491
6540
  /** Backfill the durable mute tombstones from the legacy sessions.muted column.
6492
6541
  * Both writes in setSessionMuted stay transactional, so this idempotent sync cannot
6493
6542
  * resurrect a mute that was explicitly cleared. */
@@ -6930,28 +6979,96 @@ var LocalStore = class {
6930
6979
  return this.db.prepare("SELECT lastRunAt FROM cron_runs WHERE key = ?").get(key)?.lastRunAt;
6931
6980
  }
6932
6981
  /** §6.9 #353 durable inbox: persist an admitted message BEFORE its admission ACK. Keyed
6933
- * by the stable id (deliveryId/msgId); a re-append preserves the original payload/FIFO
6982
+ * by the stable id (deliveryId/msgId). A re-append preserves the original payload/FIFO
6934
6983
  * position and may only advance the durable loop-accounting marker from 0 to 1. */
6935
6984
  appendInbox(row) {
6936
- this.db.prepare(`INSERT INTO inbox
6937
- (id, sessionKey, agentId, msg, integrationId, callMeta, isQueueCmd, loopGuardCounted, enqueuedAt)
6985
+ const inserted = this.db.prepare(`INSERT OR IGNORE INTO inbox
6986
+ (id, sessionKey, agentId, msg, integrationId, callMeta, hookContext, posterPublishState,
6987
+ terminalReport, legacyReportConnection, completedAt, isQueueCmd, loopGuardCounted, enqueuedAt)
6938
6988
  VALUES
6939
- (@id, @sessionKey, @agentId, @msg, @integrationId, @callMeta, @isQueueCmd, @loopGuardCounted, @enqueuedAt)
6940
- ON CONFLICT(id) DO UPDATE SET
6941
- loopGuardCounted=MAX(inbox.loopGuardCounted, excluded.loopGuardCounted)`).run({
6989
+ (@id, @sessionKey, @agentId, @msg, @integrationId, @callMeta, @hookContext, @posterPublishState,
6990
+ @terminalReport, @legacyReportConnection, @completedAt, @isQueueCmd, @loopGuardCounted, @enqueuedAt)`).run({
6942
6991
  id: row.id,
6943
6992
  sessionKey: row.sessionKey,
6944
6993
  agentId: row.agentId,
6945
6994
  msg: row.msg,
6946
6995
  integrationId: row.integrationId ?? null,
6947
6996
  callMeta: row.callMeta ?? null,
6997
+ hookContext: row.hookContext ?? null,
6998
+ posterPublishState: row.posterPublishState ?? null,
6999
+ terminalReport: row.terminalReport ?? null,
7000
+ legacyReportConnection: row.legacyReportConnection ?? null,
7001
+ completedAt: row.completedAt ?? null,
6948
7002
  isQueueCmd: row.isQueueCmd ?? null,
6949
7003
  loopGuardCounted: row.loopGuardCounted ?? 0,
6950
7004
  enqueuedAt: row.enqueuedAt
6951
7005
  });
7006
+ if (inserted.changes === 0 && row.loopGuardCounted === 1) this.db.prepare("UPDATE inbox SET loopGuardCounted = MAX(loopGuardCounted, 1) WHERE id = ?").run(row.id);
7007
+ return inserted.changes === 1;
6952
7008
  }
6953
- /** Remove an inbox row once its turn reaches a terminal state (success / reject / cancel /
6954
- * gate-drop) so it is never replayed. No-op on an unknown id. */
7009
+ /** Stable-id admission probe used before any hook anchoring side effect. A
7010
+ * live row will be replayed (or is already running); a terminal row is the
7011
+ * durable receipt. Either way, redelivery must not post another anchor. */
7012
+ hasInbox(id) {
7013
+ return this.db.prepare("SELECT 1 FROM inbox WHERE id = ?").get(id) !== void 0;
7014
+ }
7015
+ updateInboxHookState(id, hookContext, posterPublishState) {
7016
+ return this.db.prepare(`UPDATE inbox
7017
+ SET hookContext = @hookContext,
7018
+ posterPublishState = COALESCE(@posterPublishState, posterPublishState)
7019
+ WHERE id = @id`).run({
7020
+ id,
7021
+ hookContext,
7022
+ posterPublishState: posterPublishState ?? null
7023
+ }).changes === 1;
7024
+ }
7025
+ /** Atomically turn a live hook inbox row into a redacted terminal receipt.
7026
+ * The stable id remains present to absorb relay redelivery after restart;
7027
+ * startup re-emits only the metadata report, never the model prompt. The CAS
7028
+ * result identifies the sole writer so a later terminal path cannot replace
7029
+ * the winning outbox body. */
7030
+ completeHookInbox(id, terminalReport, completedAt) {
7031
+ if (this.db.prepare(`UPDATE inbox
7032
+ SET msg = '{}', integrationId = NULL, callMeta = NULL, hookContext = NULL,
7033
+ posterPublishState = 'settled', terminalReport = @terminalReport,
7034
+ legacyReportConnection = NULL, completedAt = @completedAt, isQueueCmd = NULL
7035
+ WHERE id = @id AND hookContext IS NOT NULL AND completedAt IS NULL`).run({
7036
+ id,
7037
+ terminalReport,
7038
+ completedAt
7039
+ }).changes === 1) return "completed";
7040
+ const row = this.db.prepare("SELECT completedAt FROM inbox WHERE id = ?").get(id);
7041
+ return row?.completedAt !== null && row?.completedAt !== void 0 ? "already-terminal" : "missing";
7042
+ }
7043
+ /** Remember a best-effort legacy EVT on this exact CP connection so an
7044
+ * outbox pump can move past it without repeatedly re-sending. The report body
7045
+ * remains retained until a future ACK-capable connection durably converges it. */
7046
+ markLegacyHookReportSent(id, connectionId) {
7047
+ return this.db.prepare(`UPDATE inbox
7048
+ SET legacyReportConnection = @connectionId
7049
+ WHERE id = @id AND terminalReport IS NOT NULL`).run({
7050
+ id,
7051
+ connectionId
7052
+ }).changes === 1;
7053
+ }
7054
+ /** A CP-correlated ACK releases only the report payload. Keep a bounded
7055
+ * metadata-only stable-id receipt so relay redelivery still cannot rerun the
7056
+ * model; unacknowledged reports are never capacity-evicted. */
7057
+ acknowledgeHookInbox(id, maxAcknowledgedReceipts = 1e4) {
7058
+ const result = this.db.prepare(`UPDATE inbox
7059
+ SET terminalReport = NULL, legacyReportConnection = NULL
7060
+ WHERE id = @id AND completedAt IS NOT NULL AND terminalReport IS NOT NULL`).run({ id });
7061
+ if (result.changes === 1) this.db.prepare(`DELETE FROM inbox
7062
+ WHERE id IN (
7063
+ SELECT id FROM inbox
7064
+ WHERE completedAt IS NOT NULL AND terminalReport IS NULL
7065
+ ORDER BY completedAt DESC, id DESC
7066
+ LIMIT -1 OFFSET @maxAcknowledgedReceipts
7067
+ )`).run({ maxAcknowledgedReceipts });
7068
+ return result.changes === 1;
7069
+ }
7070
+ /** Remove an ordinary inbox row once its turn reaches a terminal state. Hook
7071
+ * rows are converted to bounded redacted receipts by completeHookInbox. */
6955
7072
  removeInbox(id) {
6956
7073
  this.db.prepare("DELETE FROM inbox WHERE id = ?").run(id);
6957
7074
  }
@@ -6960,15 +7077,15 @@ var LocalStore = class {
6960
7077
  listInboxBySessionKeyFifo() {
6961
7078
  return this.db.prepare("SELECT * FROM inbox ORDER BY sessionKey ASC, enqueuedAt ASC").all();
6962
7079
  }
6963
- hasInbox(id) {
6964
- return this.db.prepare("SELECT 1 FROM inbox WHERE id = ?").get(id) !== void 0;
6965
- }
6966
- /** Remove every durable turn owned by an agent. Agent pause is an operator stop,
6967
- * not a drain/retry signal: work purged here must never resurrect after unpause +
6968
- * restart. Returns the removed ids so the daemon can clear its live-id cache. */
7080
+ /** Remove ordinary durable turns owned by an agent. Live hook rows have their
7081
+ * own single completion owner and must survive until that owner atomically
7082
+ * redacts them into a terminal report. Unacknowledged terminal hook reports
7083
+ * are an outbox and likewise must survive lifecycle purges. ACKed receipts
7084
+ * may be discarded here because CP already durably converged them. */
6969
7085
  removeInboxByAgentId(agentId) {
6970
- const rows = this.db.prepare("SELECT id FROM inbox WHERE agentId = ?").all(agentId);
6971
- this.db.prepare("DELETE FROM inbox WHERE agentId = ?").run(agentId);
7086
+ const removable = "agentId = ? AND hookContext IS NULL AND terminalReport IS NULL";
7087
+ const rows = this.db.prepare(`SELECT id FROM inbox WHERE ${removable}`).all(agentId);
7088
+ this.db.prepare(`DELETE FROM inbox WHERE ${removable}`).run(agentId);
6972
7089
  return rows.map((row) => row.id);
6973
7090
  }
6974
7091
  /** Record one turn admission against a conversation-wide fixed window. A trusted
@@ -7692,6 +7809,31 @@ async function executeTool(ctx, name, args, deps) {
7692
7809
  cancelled: true
7693
7810
  };
7694
7811
  }
7812
+ if (name === "submitGithubReview") {
7813
+ if (!deps.submitGithubReview) throw new Error("formal GitHub reviews are unavailable on this daemon");
7814
+ const event = requireEnum(args, "event", [
7815
+ "COMMENT",
7816
+ "REQUEST_CHANGES",
7817
+ "APPROVE"
7818
+ ]);
7819
+ const verdict = requireEnum(args, "verdict", [
7820
+ "pass",
7821
+ "fail",
7822
+ "neutral"
7823
+ ]);
7824
+ const body = requireStringAllowEmpty(args, "body");
7825
+ const comments = parseReviewComments(args.comments);
7826
+ return deps.submitGithubReview({
7827
+ agentId: ctx.agentId,
7828
+ platform: ctx.platform,
7829
+ channel: ctx.channel,
7830
+ thread: ctx.thread,
7831
+ event,
7832
+ verdict,
7833
+ body,
7834
+ ...comments ? { comments } : {}
7835
+ });
7836
+ }
7695
7837
  const gw = ctx.integrationId ? deps.gatewayFor(ctx.integrationId) : void 0;
7696
7838
  if (!gw) throw new Error(`no live platform connection for integration ${ctx.integrationId ?? "(none)"}`);
7697
7839
  switch (name) {
@@ -7778,6 +7920,35 @@ function optionalNumber(args, key) {
7778
7920
  if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`argument ${key} must be a finite number`);
7779
7921
  return v;
7780
7922
  }
7923
+ function requireEnum(args, key, values) {
7924
+ const value = args[key];
7925
+ if (typeof value !== "string" || !values.includes(value)) throw new Error(`argument ${key} must be one of: ${values.join(", ")}`);
7926
+ return value;
7927
+ }
7928
+ function requirePositiveInt(args, key) {
7929
+ const value = args[key];
7930
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`argument ${key} must be a positive integer`);
7931
+ return value;
7932
+ }
7933
+ function parseReviewComments(value) {
7934
+ if (value === void 0 || value === null) return void 0;
7935
+ if (!Array.isArray(value)) throw new Error("argument comments must be an array");
7936
+ if (value.length > 100) throw new Error("argument comments may contain at most 100 entries");
7937
+ return value.map((item, index) => {
7938
+ if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error(`comments[${index}] must be an object`);
7939
+ const row = item;
7940
+ const startLine = row.startLine === void 0 ? void 0 : requirePositiveInt(row, "startLine");
7941
+ const startSide = row.startSide === void 0 ? void 0 : requireEnum(row, "startSide", ["LEFT", "RIGHT"]);
7942
+ return {
7943
+ path: requireString(row, "path"),
7944
+ body: requireString(row, "body"),
7945
+ line: requirePositiveInt(row, "line"),
7946
+ side: requireEnum(row, "side", ["LEFT", "RIGHT"]),
7947
+ ...startLine !== void 0 ? { startLine } : {},
7948
+ ...startSide !== void 0 ? { startSide } : {}
7949
+ };
7950
+ });
7951
+ }
7781
7952
  //#endregion
7782
7953
  //#region src/mcp/control-server.ts
7783
7954
  /**
@@ -11251,7 +11422,7 @@ var SSEClientTransport = class {
11251
11422
  };
11252
11423
  //#endregion
11253
11424
  //#region src/mcp/mcp-prober.ts
11254
- const DEFAULT_TIMEOUT_MS$1 = 15e3;
11425
+ const DEFAULT_TIMEOUT_MS$2 = 15e3;
11255
11426
  const DEFAULT_CONCURRENCY$1 = 3;
11256
11427
  function envRecord(pairs) {
11257
11428
  return Object.fromEntries(pairs.map((e) => [e.name, e.value]));
@@ -11290,7 +11461,7 @@ async function connectReal(name, def) {
11290
11461
  * not a failure.
11291
11462
  */
11292
11463
  async function probeMcpServer(name, def, opts = {}) {
11293
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
11464
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$2;
11294
11465
  let timer;
11295
11466
  let timedOut = false;
11296
11467
  const timeout = new Promise((_, reject) => {
@@ -197666,7 +197837,10 @@ const UNTRUSTED_CONTENT_BEGIN = "----- BEGIN UNTRUSTED EXTERNAL CONTENT (GitHub
197666
197837
  const UNTRUSTED_CONTENT_END = "----- END UNTRUSTED EXTERNAL CONTENT -----";
197667
197838
  /** channel/thread from the affinity key — see the sessionKey grammar on {@link RdMsgHook}. */
197668
197839
  function splitSessionKey(msg) {
197669
- if (msg.sessionKey === msg.hookId) return { channel: msg.hookId };
197840
+ if (msg.sessionKey === msg.hookId) return {
197841
+ channel: msg.hookId,
197842
+ thread: msg.hookId
197843
+ };
197670
197844
  if (msg.sessionKey === `${msg.hookId}:${msg.deliveryKey}`) return {
197671
197845
  channel: msg.hookId,
197672
197846
  thread: msg.deliveryKey
@@ -197731,7 +197905,7 @@ function neutralizeDelimiters(body) {
197731
197905
  * Only emitted when the poster will actually run (a thread number exists); push
197732
197906
  * fires have no thread, so they never see this. */
197733
197907
  function githubReplyHint(c) {
197734
- return ["", `Your final reply is posted back to ${`${c.repo ?? "this thread"}#${c.number}`} automatically as a GitHub comment. Keep it self-contained. The daemon exclusively owns that reply comment. Do NOT create, update, or delete GitHub issue/PR comments or reviews through any CLI (including \`gh\`), MCP/connector, API, or other tool — that would race or double-post with the automatic poster. Use GitHub tools only for READ-only inspection (viewing the thread, diffs, files), then return the content to publish in your final reply.`].join("\n");
197908
+ return ["", `Your final reply is posted back to ${`${c.repo ?? "this thread"}#${c.number}`} automatically as an ordinary GitHub comment. Keep it self-contained; the daemon exclusively owns that reply comment. If this active PR hook permits a formal review, use only the structured \`submitGithubReview\` tool for COMMENT / REQUEST_CHANGES / APPROVE and inline review comments. Do NOT create, update, or delete GitHub comments or formal reviews through \`gh\`, another CLI, a connector, or a direct API call — those paths would race or double-post. Other GitHub tools are for READ-only inspection (thread, diff, files), then return the ordinary comment content in your final reply.`].join("\n");
197735
197909
  }
197736
197910
  /** The github-kind turn text: a trusted metadata header + the FENCED excerpt.
197737
197911
  * The title rides the header (relay-sanitized to one capped line) — it is
@@ -198125,6 +198299,227 @@ var GithubFinalPoster = class {
198125
198299
  }
198126
198300
  };
198127
198301
  //#endregion
198302
+ //#region src/github/review.ts
198303
+ const DEFAULT_TIMEOUT_MS$1 = 15e3;
198304
+ const MAX_REVIEW_PAGES = 10;
198305
+ const REVIEWS_PER_PAGE = 100;
198306
+ var GithubHttpError = class extends Error {
198307
+ status;
198308
+ constructor(status, message) {
198309
+ super(message);
198310
+ this.status = status;
198311
+ this.name = "GithubHttpError";
198312
+ }
198313
+ };
198314
+ function markerFor(target) {
198315
+ return `<!-- agentconnect-review:${Buffer.from(JSON.stringify({
198316
+ v: 1,
198317
+ hookId: target.hookId,
198318
+ deliveryKey: target.deliveryKey,
198319
+ attemptId: target.attemptId,
198320
+ headSha: target.expectedHeadSha
198321
+ }), "utf8").toString("base64url")} -->`;
198322
+ }
198323
+ function validateInput(input) {
198324
+ if (input.event === "APPROVE" && input.verdict !== "pass") return "APPROVE requires verdict=pass";
198325
+ if (input.event === "REQUEST_CHANGES" && input.verdict !== "fail") return "REQUEST_CHANGES requires verdict=fail";
198326
+ if ((input.event === "COMMENT" || input.event === "REQUEST_CHANGES") && !input.body.trim()) return `${input.event} requires a non-empty body`;
198327
+ for (const [index, comment] of (input.comments ?? []).entries()) {
198328
+ if (!comment.path.trim()) return `comments[${index}].path is required`;
198329
+ if (!comment.body.trim()) return `comments[${index}].body is required`;
198330
+ if (!Number.isInteger(comment.line) || comment.line <= 0) return `comments[${index}].line must be positive`;
198331
+ if (comment.startLine !== void 0) {
198332
+ if (!Number.isInteger(comment.startLine) || comment.startLine <= 0 || comment.startLine > comment.line) return `comments[${index}].startLine must be positive and no greater than line`;
198333
+ if (!comment.startSide) return `comments[${index}].startSide is required with startLine`;
198334
+ }
198335
+ }
198336
+ }
198337
+ function safeJson(text) {
198338
+ if (!text) return void 0;
198339
+ return JSON.parse(text.replace(/"id"\s*:\s*(\d{15,})/g, "\"id\":\"$1\""));
198340
+ }
198341
+ /** One lexical-scope client; it never caches tokens or targets. */
198342
+ var GithubReviewClient = class {
198343
+ fetchImpl;
198344
+ baseUrl;
198345
+ timeoutMs;
198346
+ constructor(deps = {}) {
198347
+ this.fetchImpl = deps.fetchImpl ?? fetch;
198348
+ this.baseUrl = (deps.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
198349
+ this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
198350
+ }
198351
+ async getPull(token, repoFullName, pullNumber) {
198352
+ const pull = await this.request(`/repos/${repoFullName}/pulls/${pullNumber}`, token, "GET");
198353
+ const headSha = pull.head?.sha;
198354
+ const baseSha = pull.base?.sha;
198355
+ if (!headSha || !baseSha) throw new GithubHttpError(502, "GitHub pull response omitted head/base SHA");
198356
+ return {
198357
+ headSha,
198358
+ baseSha,
198359
+ draft: pull.draft === true,
198360
+ state: pull.state ?? "unknown",
198361
+ merged: pull.merged === true
198362
+ };
198363
+ }
198364
+ async submit(target, input) {
198365
+ const invalid = validateInput(input);
198366
+ if (invalid) return {
198367
+ state: "not_submitted",
198368
+ code: "invalid_input",
198369
+ message: invalid
198370
+ };
198371
+ const marker = markerFor(target);
198372
+ try {
198373
+ let existing;
198374
+ try {
198375
+ existing = await this.findByMarker(target, marker);
198376
+ } catch (err) {
198377
+ if (target.recovering) return {
198378
+ state: "ambiguous",
198379
+ code: "ambiguous_write",
198380
+ message: "cannot reconcile the prior formal review attempt; automatic retry is blocked"
198381
+ };
198382
+ throw err;
198383
+ }
198384
+ if (existing) return this.submitted(existing, target, input);
198385
+ const pull = await this.getPull(target.token, target.repoFullName, target.pullNumber);
198386
+ if (pull.state !== "open" || pull.merged || pull.draft) return {
198387
+ state: "not_submitted",
198388
+ code: "pull_unavailable",
198389
+ message: `pull request is ${pull.merged ? "merged" : pull.draft ? "draft" : pull.state}`
198390
+ };
198391
+ if (pull.headSha !== target.expectedHeadSha || pull.baseSha !== target.expectedBaseSha) return {
198392
+ state: "not_submitted",
198393
+ code: "revision_changed",
198394
+ message: "pull request head/base changed while the review was running"
198395
+ };
198396
+ const body = input.body.trim() ? `${input.body}\n\n${marker}` : marker;
198397
+ try {
198398
+ const created = await this.request(`/repos/${target.repoFullName}/pulls/${target.pullNumber}/reviews`, target.token, "POST", {
198399
+ commit_id: target.expectedHeadSha,
198400
+ event: input.event,
198401
+ body,
198402
+ ...input.comments?.length ? { comments: input.comments.map((comment) => ({
198403
+ path: comment.path,
198404
+ body: comment.body,
198405
+ line: comment.line,
198406
+ side: comment.side,
198407
+ ...comment.startLine !== void 0 ? { start_line: comment.startLine } : {},
198408
+ ...comment.startSide !== void 0 ? { start_side: comment.startSide } : {}
198409
+ })) } : {}
198410
+ });
198411
+ return this.submitted(created, target, input);
198412
+ } catch (err) {
198413
+ if (err instanceof GithubHttpError && err.status >= 400 && err.status < 500) return {
198414
+ state: "not_submitted",
198415
+ code: "github_rejected",
198416
+ message: err.message
198417
+ };
198418
+ const recovered = await this.findByMarker(target, marker).catch(() => void 0);
198419
+ if (recovered) return this.submitted(recovered, target, input);
198420
+ return {
198421
+ state: "ambiguous",
198422
+ code: "ambiguous_write",
198423
+ message: "GitHub review outcome is unknown; automatic retry is blocked"
198424
+ };
198425
+ }
198426
+ } catch (err) {
198427
+ return {
198428
+ state: "not_submitted",
198429
+ code: "github_rejected",
198430
+ message: err instanceof Error ? err.message : String(err)
198431
+ };
198432
+ }
198433
+ }
198434
+ /** Read-only recovery for an attempt whose POST outcome was ambiguous. This
198435
+ * is safe to run automatically before a replayed model turn: it can converge
198436
+ * a now-visible marker, but never issues a second review mutation. */
198437
+ async reconcile(target, event, verdict) {
198438
+ const marker = markerFor(target);
198439
+ try {
198440
+ const existing = await this.findByMarker(target, marker);
198441
+ if (existing) return this.submitted(existing, target, {
198442
+ event,
198443
+ verdict,
198444
+ body: ""
198445
+ });
198446
+ return {
198447
+ state: "ambiguous",
198448
+ code: "ambiguous_write",
198449
+ message: "the prior formal review marker is not visible yet; automatic mutation retry is blocked"
198450
+ };
198451
+ } catch {
198452
+ return {
198453
+ state: "ambiguous",
198454
+ code: "ambiguous_write",
198455
+ message: "cannot reconcile the prior formal review attempt; automatic mutation retry is blocked"
198456
+ };
198457
+ }
198458
+ }
198459
+ submitted(review, target, input) {
198460
+ if (review.id === void 0 || review.id === null) return {
198461
+ state: "ambiguous",
198462
+ code: "ambiguous_write",
198463
+ message: "GitHub created/recovered a review without an id"
198464
+ };
198465
+ if (!review.commit_id) return {
198466
+ state: "ambiguous",
198467
+ code: "ambiguous_write",
198468
+ message: "GitHub created/recovered a review without its commit id"
198469
+ };
198470
+ const commitId = review.commit_id;
198471
+ if (commitId !== target.expectedHeadSha) return {
198472
+ state: "ambiguous",
198473
+ code: "ambiguous_write",
198474
+ message: "recovered review is anchored to an unexpected commit"
198475
+ };
198476
+ return {
198477
+ state: "submitted",
198478
+ reviewId: String(review.id),
198479
+ event: input.event,
198480
+ verdict: input.verdict,
198481
+ commitId
198482
+ };
198483
+ }
198484
+ async findByMarker(target, marker) {
198485
+ for (let page = 1; page <= MAX_REVIEW_PAGES; page += 1) {
198486
+ const rows = await this.request(`/repos/${target.repoFullName}/pulls/${target.pullNumber}/reviews?per_page=${REVIEWS_PER_PAGE}&page=${page}`, target.token, "GET");
198487
+ const hit = rows.find((review) => review.body?.includes(marker));
198488
+ if (hit) return hit;
198489
+ if (rows.length < REVIEWS_PER_PAGE) return void 0;
198490
+ }
198491
+ throw new Error("review marker reconciliation exceeded pagination bound");
198492
+ }
198493
+ async request(path, token, method, body) {
198494
+ let response;
198495
+ try {
198496
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
198497
+ method,
198498
+ headers: {
198499
+ authorization: `Bearer ${token}`,
198500
+ accept: "application/vnd.github+json",
198501
+ "x-github-api-version": "2022-11-28",
198502
+ ...body === void 0 ? {} : { "content-type": "application/json" }
198503
+ },
198504
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
198505
+ signal: AbortSignal.timeout(this.timeoutMs)
198506
+ });
198507
+ } catch (err) {
198508
+ throw new Error(`GitHub request failed: ${err instanceof Error ? err.message : String(err)}`);
198509
+ }
198510
+ const text = await response.text();
198511
+ if (!response.ok) {
198512
+ let detail = "";
198513
+ try {
198514
+ const parsed = safeJson(text);
198515
+ detail = parsed?.message ? `: ${parsed.message}` : "";
198516
+ } catch {}
198517
+ throw new GithubHttpError(response.status, `GitHub ${method} ${response.status}${detail}`);
198518
+ }
198519
+ return safeJson(text);
198520
+ }
198521
+ };
198522
+ //#endregion
198128
198523
  //#region src/runtimes/runtime-prober.ts
198129
198524
  const DEFAULT_TIMEOUT_MS = 3e4;
198130
198525
  const DEFAULT_CONCURRENCY = 3;
@@ -198358,7 +198753,7 @@ var RelayClient = class {
198358
198753
  const frame = decoded.frame;
198359
198754
  if (frame.corr && this.correlator.settle(frame)) return;
198360
198755
  if (frame.type === "rd/msg") {
198361
- this.handleMsg(frame.id, frame.payload);
198756
+ await this.handleMsg(frame.id, frame.payload);
198362
198757
  return;
198363
198758
  }
198364
198759
  if (frame.type === "rd/agentmsg/fwd") {
@@ -198370,9 +198765,9 @@ var RelayClient = class {
198370
198765
  /** An inbound rd/msg: dispatch it, reply `rd/ack`; only a webchat op streams
198371
198766
  * `rd/chat` back over this socket. A shared-bot `im` replies out-of-band via the
198372
198767
  * daemon's own Slack send, and a hook fire has no reply stream — both get a noop. */
198373
- handleMsg(reqId, msg) {
198768
+ async handleMsg(reqId, msg) {
198374
198769
  const chat = msg.source === "webchat" ? (event) => this.sendChat(msg.chatId, event) : () => void 0;
198375
- const ack = this.deps.onRelayMsg(msg, chat);
198770
+ const ack = await this.deps.onRelayMsg(msg, chat);
198376
198771
  this.transport?.send(JSON.stringify(buildRelayDaemonFrame("rd/ack", ack, { corr: reqId })));
198377
198772
  }
198378
198773
  /** A forwarded cross-daemon agent-call: terminal-verify + dispatch, reply the typed
@@ -215511,10 +215906,35 @@ function isBuiltinSystemTool(params) {
215511
215906
  tc?.toolCallId
215512
215907
  ].some((id) => typeof id === "string" && [...BUILTIN_TOOL_FQNS].some((fqn) => id.includes(fqn)));
215513
215908
  }
215909
+ function hookSnapshot(msg) {
215910
+ if (msg.configRevision === void 0 || msg.dispatchRevision === void 0 || msg.dispatchDaemonId === void 0 || msg.reviewPolicy === void 0 || msg.reportingMode === void 0 || msg.gateMode === void 0) return void 0;
215911
+ return {
215912
+ configRevision: msg.configRevision,
215913
+ dispatchRevision: msg.dispatchRevision,
215914
+ dispatchDaemonId: msg.dispatchDaemonId,
215915
+ reviewPolicy: msg.reviewPolicy,
215916
+ reportingMode: msg.reportingMode,
215917
+ gateMode: msg.gateMode
215918
+ };
215919
+ }
215920
+ function reviewPolicyAllows(policy, event) {
215921
+ if (policy === "full") return true;
215922
+ if (policy === "request_changes") return event === "COMMENT" || event === "REQUEST_CHANGES";
215923
+ if (policy === "comment") return event === "COMMENT";
215924
+ return false;
215925
+ }
215926
+ function reviewResultForWire(effect) {
215927
+ if (effect.state === "submitted") return effect;
215928
+ return {
215929
+ state: effect.state,
215930
+ code: effect.code
215931
+ };
215932
+ }
215514
215933
  const MAX_QUEUED_PER_SESSION = 10;
215515
215934
  const LOOP_GUARD_WINDOW_MS = 6e4;
215516
215935
  const MAX_AUTOMATIC_TURNS_PER_WINDOW = 8;
215517
215936
  const MAX_TOTAL_TURNS_PER_WINDOW = 60;
215937
+ const MAX_HOOK_REPORT_INFLIGHT = 100;
215518
215938
  /** Thrown to a `dispatch()` caller when the per-session admission queue is at its depth
215519
215939
  * cap (§4.4 backpressure): the message is fast-failed, not buffered. Carries a stable
215520
215940
  * `reason` so an agent-call source (P1/P2) can surface a typed `queue_full`. */
@@ -215610,6 +216030,22 @@ function usesLoopGuard(msg) {
215610
216030
  function isMalformedPlatformTurn(msg) {
215611
216031
  return msg.platform === "slack" && msg.source === "user" && !msg.sender.isBot && msg.sender.id === "unknown" && msg.text.trim() === "" && (msg.attachments?.length ?? 0) === 0;
215612
216032
  }
216033
+ function authorizedReviewTargetMatches(active, attemptId, authorized) {
216034
+ return authorized.attemptId === attemptId && authorized.repoId === active.repoId && authorized.repoFullName.toLowerCase() === active.repoFullName.toLowerCase() && authorized.pullNumber === active.pullNumber && authorized.expectedHeadSha === active.expectedHeadSha && authorized.expectedBaseSha === active.expectedBaseSha;
216035
+ }
216036
+ function authorizedReviewTarget(active, attemptId, authorized, recovering = false) {
216037
+ return {
216038
+ token: authorized.token,
216039
+ repoFullName: authorized.repoFullName,
216040
+ pullNumber: authorized.pullNumber,
216041
+ expectedHeadSha: authorized.expectedHeadSha,
216042
+ expectedBaseSha: authorized.expectedBaseSha,
216043
+ hookId: active.hook.hookId,
216044
+ deliveryKey: active.hook.deliveryKey,
216045
+ attemptId,
216046
+ ...recovering ? { recovering: true } : {}
216047
+ };
216048
+ }
215613
216049
  function isSlackStatusBarText(text) {
215614
216050
  return text.startsWith(":bar_chart:") || text.startsWith("📊");
215615
216051
  }
@@ -215694,6 +216130,10 @@ var Daemon = class Daemon {
215694
216130
  botUserIds = {};
215695
216131
  cpRouting;
215696
216132
  cpCollab = new CpCollabRoutes();
216133
+ /** One active PR hook turn per logical session key. Long-lived ACP sessions
216134
+ * may span deliveries, so authorization must never live in SessionContext. */
216135
+ activeGithubTurnMeta = /* @__PURE__ */ new Map();
216136
+ githubReviewClient = new GithubReviewClient();
215697
216137
  clock;
215698
216138
  requestExit;
215699
216139
  draining = false;
@@ -215717,6 +216157,9 @@ var Daemon = class Daemon {
215717
216157
  cancelTimers = /* @__PURE__ */ new Map();
215718
216158
  coldCancelTimers = /* @__PURE__ */ new Map();
215719
216159
  slackRetryTimers = /* @__PURE__ */ new Map();
216160
+ hookReportRetryTimer;
216161
+ hookReportInflight = /* @__PURE__ */ new Set();
216162
+ hookReportConnectionId = randomUUID();
215720
216163
  slackRetryRuns = /* @__PURE__ */ new Map();
215721
216164
  constructor(opts = {}) {
215722
216165
  this.opts = opts;
@@ -215846,6 +216289,7 @@ var Daemon = class Daemon {
215846
216289
  startOrchestration: (req) => this.startOrchestration(req),
215847
216290
  getOrchestration: (req) => Promise.resolve(this.getOrchestrationForOwner(req)),
215848
216291
  cancelOrchestration: (req) => Promise.resolve(this.cancelOrchestrationForOwner(req)),
216292
+ submitGithubReview: (req) => this.submitGithubReview(req),
215849
216293
  memory: this.memory,
215850
216294
  recordOutbound: (ctx, channel, thread, text, ts) => this.store.appendTranscript({
215851
216295
  channel,
@@ -215867,6 +216311,7 @@ var Daemon = class Daemon {
215867
216311
  mcpServersFor: ({ agent, platform, channel, thread, integrationId, isDm }) => {
215868
216312
  const servers = [];
215869
216313
  let tools = toolsForIntegrations(agent.integrations);
216314
+ tools = [...tools, ...GITHUB_REVIEW_TOOLS];
215870
216315
  if (memoryKindOf(agent) === "native") tools = tools.filter((t) => !MEMORY_TOOL_NAMES.has(t.name));
215871
216316
  if (tools.length > 0) {
215872
216317
  const token = this.mcp.register({
@@ -216874,6 +217319,10 @@ var Daemon = class Daemon {
216874
217319
  this.log.debug(`webchat cancel: no in-flight turn for conversation ${conversationId}`);
216875
217320
  }
216876
217321
  relayMsgAcks = /* @__PURE__ */ new Map();
217322
+ /** Hook admission crosses an async anchor + durable-inbox barrier. Coalesce a
217323
+ * retransmit that arrives before that barrier settles instead of dispatching
217324
+ * the same delivery twice. */
217325
+ pendingRelayMsgAcks = /* @__PURE__ */ new Map();
216877
217326
  agentCallDeliveries = /* @__PURE__ */ new Map();
216878
217327
  orchestrationDeadlines = /* @__PURE__ */ new Map();
216879
217328
  /**
@@ -216895,7 +217344,26 @@ var Daemon = class Daemon {
216895
217344
  this.log.debug(`relay: duplicate rd/msg ${dedupKey} — replaying ack (no re-dispatch)`);
216896
217345
  return prior;
216897
217346
  }
216898
- const ack = msg.source === "webchat" ? this.dispatchRelayOp(msg, chat) : msg.source === "im" ? this.handleRelayIm(msg) : this.dispatchRelayHook(msg);
217347
+ const pending = this.pendingRelayMsgAcks.get(dedupKey);
217348
+ if (pending) return pending;
217349
+ if (msg.source === "hook") {
217350
+ const task = this.dispatchRelayHook(msg).catch((err) => {
217351
+ this.log.error(`hook admission failed for ${dedupKey}: ${formatErr(err)}`);
217352
+ return {
217353
+ msgId: msg.msgId,
217354
+ accepted: false,
217355
+ reason: "durability"
217356
+ };
217357
+ }).then((ack) => {
217358
+ this.pendingRelayMsgAcks.delete(dedupKey);
217359
+ if (this.relayMsgAcks.size >= 2e3) this.relayMsgAcks.clear();
217360
+ this.relayMsgAcks.set(dedupKey, ack);
217361
+ return ack;
217362
+ });
217363
+ this.pendingRelayMsgAcks.set(dedupKey, task);
217364
+ return task;
217365
+ }
217366
+ const ack = msg.source === "webchat" ? this.dispatchRelayOp(msg, chat) : this.handleRelayIm(msg);
216899
217367
  if (this.relayMsgAcks.size >= 2e3) this.relayMsgAcks.clear();
216900
217368
  this.relayMsgAcks.set(dedupKey, ack);
216901
217369
  return ack;
@@ -217397,9 +217865,10 @@ var Daemon = class Daemon {
217397
217865
  * Ack-verdict gate for one hook fire (`rd/msg` hook member) — the mirror of
217398
217866
  * {@link dispatchWebchatTurn}'s synchronous gates: the relay's rc/run-report
217399
217867
  * needs a REASONED rejection now, not a silently dropped fire-and-forget
217400
- * dispatch. Accepted fires run async via {@link onHookFire}.
217868
+ * dispatch. Accepted is returned only after {@link onHookFire} has crossed
217869
+ * the durable-inbox admission barrier; the model turn itself remains async.
217401
217870
  */
217402
- dispatchRelayHook(msg) {
217871
+ async dispatchRelayHook(msg) {
217403
217872
  if (!this.agents.has(msg.agentId)) {
217404
217873
  this.log.warn(`hook: no agent "${msg.agentId}" on this daemon — rejecting fire ${msg.msgId}`);
217405
217874
  return {
@@ -217432,10 +217901,11 @@ var Daemon = class Daemon {
217432
217901
  reason: "draining"
217433
217902
  };
217434
217903
  }
217435
- this.onHookFire(msg).catch((err) => this.log.error(`hook fire dispatch failed for agent "${msg.agentId}": ${formatErr(err)}`));
217904
+ const admission = await this.onHookFire(msg);
217436
217905
  return {
217437
217906
  msgId: msg.msgId,
217438
- accepted: true
217907
+ accepted: admission.accepted,
217908
+ ...admission.reason ? { reason: admission.reason } : {}
217439
217909
  };
217440
217910
  }
217441
217911
  /**
@@ -217448,25 +217918,36 @@ var Daemon = class Daemon {
217448
217918
  * without one the fire runs headless.
217449
217919
  */
217450
217920
  async onHookFire(msg) {
217451
- const firedAt = this.clock.now();
217452
- const report = (status, extra = {}) => {
217453
- this.cpClient?.emitHookReport({
217454
- hookId: msg.hookId,
217455
- agentId: msg.agentId,
217456
- deliveryKey: msg.deliveryKey,
217457
- status,
217458
- durationMs: Math.max(0, this.clock.now() - firedAt),
217459
- ...extra
217460
- });
217461
- };
217462
217921
  const nmsg = buildHookMessage(msg, randomUUID());
217922
+ if (this.store.hasInbox(nmsg.msgId)) {
217923
+ this.log.debug(`hook: durable duplicate ${nmsg.msgId} — replaying accepted admission`);
217924
+ return { accepted: true };
217925
+ }
217926
+ const snapshot = hookSnapshot(msg);
217927
+ const hookContext = {
217928
+ hookId: msg.hookId,
217929
+ agentId: msg.agentId,
217930
+ deliveryKey: msg.deliveryKey,
217931
+ firedAt: msg.firedAt,
217932
+ ...msg.event ? { event: msg.event } : {},
217933
+ ...snapshot ? { snapshot } : {},
217934
+ ...msg.github ? { github: msg.github } : {}
217935
+ };
217463
217936
  const c = msg.context;
217464
217937
  if (c?.source === "github" && c.action === "deleted" && !msg.target) {
217465
217938
  const key = sessionKey(nmsg.platform, nmsg.channel, nmsg.thread ?? nmsg.msgId, msg.agentId);
217466
217939
  if (!this.store.getSession(key)?.acpSessionId) {
217467
217940
  this.log.info(`hook: skipping github ${c.event ?? "event"}:deleted fire ${msg.msgId} — no session for ${key}`);
217468
- report("success", { reason: "deleted: no existing session" });
217469
- return;
217941
+ const entry = {
217942
+ agentId: msg.agentId,
217943
+ msg: nmsg,
217944
+ initAbort: new AbortController(),
217945
+ hookContext,
217946
+ resolve: () => {},
217947
+ reject: () => {}
217948
+ };
217949
+ if (this.persistInbox(entry, key, { required: true }) !== "existing") this.emitHookCompletion(hookContext, "success", { reason: "deleted: no existing session" }, entry);
217950
+ return { accepted: true };
217470
217951
  }
217471
217952
  }
217472
217953
  const githubReply = c?.source === "github" && c.repo && c.number !== void 0 ? {
@@ -217474,14 +217955,223 @@ var Daemon = class Daemon {
217474
217955
  repo: c.repo,
217475
217956
  number: c.number
217476
217957
  } : void 0;
217958
+ if (githubReply) hookContext.githubReply = githubReply;
217959
+ const anchored = await this.anchorTrigger(msg.agentId, nmsg, msg.target, hookAnchorText(msg), `hook "${msg.hookId}"`);
217960
+ if (!anchored) return {
217961
+ accepted: false,
217962
+ reason: "dropped"
217963
+ };
217964
+ let admission;
217965
+ this.dispatch(msg.agentId, anchored, msg.target?.integrationId, void 0, void 0, {
217966
+ requireDurable: true,
217967
+ onAdmission: (result) => {
217968
+ admission = result;
217969
+ }
217970
+ }, githubReply, hookContext).catch((err) => this.log.error(`hook turn failed for agent "${msg.agentId}": ${formatErr(err)}`));
217971
+ if (!admission) throw new Error("hook admission barrier did not settle synchronously");
217972
+ if (!admission.accepted) return {
217973
+ accepted: false,
217974
+ reason: admission.reason ?? "durability"
217975
+ };
217976
+ return { accepted: true };
217977
+ }
217978
+ /** Resolve a PR revision if the webhook omitted it (notably
217979
+ * issue_comment), cross the CP hook/start barrier, then return the active
217980
+ * review authority. Failure only disables structured effects; the ordinary
217981
+ * agent turn and final poster continue. */
217982
+ async prepareGithubTurn(entry) {
217983
+ const hook = entry.hookContext;
217984
+ const snapshot = hook?.snapshot;
217985
+ const github = hook?.github;
217986
+ if (!hook || !snapshot || !github || github.subjectKind !== "pull_request" || github.pullNumber === void 0 || snapshot.reviewPolicy === "off" && snapshot.reportingMode === "off" || snapshot.gateMode !== "informational" || snapshot.reportingMode === "status") return void 0;
217987
+ if (this.cfg.daemonId && snapshot.dispatchDaemonId !== this.cfg.daemonId) {
217988
+ this.log.warn(`github review: stale dispatch daemon for ${hook.hookId}:${hook.deliveryKey}`);
217989
+ return;
217990
+ }
217991
+ if (!github.headSha || !github.baseSha) try {
217992
+ const postToken = await this.gitCreds.getPostToken(hook.agentId, github.repoFullName, hook.hookId);
217993
+ const revision = await this.githubReviewClient.getPull(postToken.token, github.repoFullName, github.pullNumber);
217994
+ hook.github = {
217995
+ ...github,
217996
+ headSha: revision.headSha,
217997
+ baseSha: revision.baseSha,
217998
+ reportSha: revision.headSha,
217999
+ isDraft: revision.draft
218000
+ };
218001
+ this.persistHookState(entry, void 0, true);
218002
+ } catch (err) {
218003
+ this.log.warn(`github review: unable to resolve PR revision (${formatErr(err)})`);
218004
+ return;
218005
+ }
218006
+ const trusted = hook.github;
218007
+ if (!trusted.headSha || !trusted.baseSha || trusted.pullNumber === void 0) return void 0;
218008
+ const client = this.cpClient;
218009
+ if (!client) return void 0;
218010
+ const payload = {
218011
+ hookId: hook.hookId,
218012
+ agentId: hook.agentId,
218013
+ deliveryKey: hook.deliveryKey,
218014
+ ...hook.event ? { event: hook.event } : {},
218015
+ github: {
218016
+ ...trusted,
218017
+ reportSha: trusted.reportSha ?? trusted.headSha
218018
+ },
218019
+ ...snapshot
218020
+ };
218021
+ let started = false;
218022
+ for (let attempt = 0; attempt < 3 && !started; attempt += 1) try {
218023
+ await client.startHook(payload);
218024
+ started = true;
218025
+ } catch (err) {
218026
+ if (attempt === 2) {
218027
+ this.log.warn(`github review: hook/start rejected (${formatErr(err)})`);
218028
+ return;
218029
+ }
218030
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
218031
+ }
218032
+ if (snapshot.reviewPolicy === "off") return void 0;
218033
+ const recoverableAttempt = hook.reviewAttemptId !== void 0 && hook.reviewRequestedEvent !== void 0 && hook.reviewRequestedVerdict !== void 0 && (hook.reviewResult === void 0 || hook.reviewResult.state === "ambiguous");
218034
+ const active = {
218035
+ entry,
218036
+ hook,
218037
+ snapshot,
218038
+ repoId: trusted.repoId,
218039
+ repoFullName: trusted.repoFullName,
218040
+ pullNumber: trusted.pullNumber,
218041
+ expectedHeadSha: trusted.headSha,
218042
+ expectedBaseSha: trusted.baseSha,
218043
+ reportSha: trusted.reportSha ?? trusted.headSha,
218044
+ reviewState: hook.reviewAttemptId === void 0 || recoverableAttempt ? "idle" : "done"
218045
+ };
218046
+ if (recoverableAttempt) await this.reconcileGithubReviewAttempt(active);
218047
+ return active;
218048
+ }
218049
+ /** Store the immediate and terminal-report copies together so they cannot
218050
+ * drift across submit and restart-recovery paths. */
218051
+ persistGithubReviewEffect(active, attemptId, effect, required = false) {
218052
+ const result = reviewResultForWire(effect);
218053
+ active.hook.reviewResult = result;
218054
+ active.hook.reviewReportAttemptId = attemptId;
218055
+ active.hook.reviewReportResult = result;
218056
+ this.persistHookState(active.entry, void 0, required);
218057
+ return result;
218058
+ }
218059
+ /** A daemon restart may replay an attempt after an ambiguous POST/list race.
218060
+ * Before prompting the model, perform a GET-only marker reconciliation. A
218061
+ * still-missing marker remains blocked; only an explicit tool invocation can
218062
+ * cross the full reauthorization + revision fence and retry the mutation. */
218063
+ async reconcileGithubReviewAttempt(active) {
218064
+ const cp = this.cpClient;
218065
+ const attemptId = active.hook.reviewAttemptId;
218066
+ const requestedEvent = active.hook.reviewRequestedEvent;
218067
+ const requestedVerdict = active.hook.reviewRequestedVerdict;
218068
+ if (!cp || !attemptId || !requestedEvent || !requestedVerdict) return;
218069
+ try {
218070
+ const authorized = await cp.authorizeGithubReview({
218071
+ hookId: active.hook.hookId,
218072
+ deliveryKey: active.hook.deliveryKey,
218073
+ attemptId,
218074
+ requestedEvent,
218075
+ requestedVerdict,
218076
+ snapshot: active.snapshot
218077
+ });
218078
+ if (!authorizedReviewTargetMatches(active, attemptId, authorized)) {
218079
+ this.log.warn("github review: recovery authorization returned a mismatched target");
218080
+ return;
218081
+ }
218082
+ const effect = await this.githubReviewClient.reconcile(authorizedReviewTarget(active, attemptId, authorized, true), requestedEvent, requestedVerdict);
218083
+ if (effect.state !== "submitted") return;
218084
+ const result = this.persistGithubReviewEffect(active, attemptId, effect, true);
218085
+ await cp.reportGithubReviewResult({
218086
+ hookId: active.hook.hookId,
218087
+ deliveryKey: active.hook.deliveryKey,
218088
+ attemptId,
218089
+ snapshot: active.snapshot,
218090
+ result
218091
+ });
218092
+ active.reviewState = "done";
218093
+ } catch (err) {
218094
+ this.log.warn(`github review: replay reconciliation deferred (${formatErr(err)})`);
218095
+ }
218096
+ }
218097
+ async submitGithubReview(req) {
218098
+ const key = sessionKey(req.platform, req.channel, req.thread, req.agentId);
218099
+ const active = this.activeGithubTurnMeta.get(key);
218100
+ if (!active || active.hook.agentId !== req.agentId) throw new Error("formal GitHub review is only available during the active PR hook turn");
218101
+ if (!reviewPolicyAllows(active.snapshot.reviewPolicy, req.event)) throw new Error(`${req.event} exceeds this hook's ${active.snapshot.reviewPolicy} review policy`);
218102
+ if (active.reviewState !== "idle") throw new Error("this PR hook turn already has a formal review attempt");
218103
+ active.reviewState = "submitting";
218104
+ const recovering = active.hook.reviewAttemptId !== void 0;
218105
+ if (recovering) {
218106
+ if (active.hook.reviewRequestedEvent === void 0 || active.hook.reviewRequestedVerdict === void 0 || active.hook.reviewRequestedEvent !== req.event || active.hook.reviewRequestedVerdict !== req.verdict) {
218107
+ active.reviewState = "idle";
218108
+ throw new Error("a recovered formal-review attempt must keep its original event and verdict");
218109
+ }
218110
+ } else {
218111
+ active.hook.reviewRequestedEvent = req.event;
218112
+ active.hook.reviewRequestedVerdict = req.verdict;
218113
+ }
218114
+ const attemptId = active.hook.reviewAttemptId ?? randomUUID();
218115
+ active.hook.reviewAttemptId = attemptId;
218116
+ const entry = active.entry;
217477
218117
  try {
217478
- const sessionId = await this.fireTrigger(msg.agentId, nmsg, msg.target, hookAnchorText(msg), `hook "${msg.hookId}"`, githubReply);
217479
- if (sessionId !== null) report("success", { sessionId });
217480
- else report("failed", { reason: "dropped" });
218118
+ this.persistHookState(entry, void 0, true);
217481
218119
  } catch (err) {
217482
- report("failed", { reason: err.message?.slice(0, 300) || "dispatch failed" });
218120
+ if (!recovering) {
218121
+ delete active.hook.reviewAttemptId;
218122
+ delete active.hook.reviewRequestedEvent;
218123
+ delete active.hook.reviewRequestedVerdict;
218124
+ }
218125
+ active.reviewState = "idle";
218126
+ throw new Error(`formal review durability barrier failed: ${formatErr(err)}`);
218127
+ }
218128
+ const cp = this.cpClient;
218129
+ if (!cp) {
218130
+ active.reviewState = "done";
218131
+ throw new Error("control plane is not connected; formal review denied");
218132
+ }
218133
+ let authorized;
218134
+ try {
218135
+ authorized = await cp.authorizeGithubReview({
218136
+ hookId: active.hook.hookId,
218137
+ deliveryKey: active.hook.deliveryKey,
218138
+ attemptId,
218139
+ requestedEvent: req.event,
218140
+ requestedVerdict: req.verdict,
218141
+ snapshot: active.snapshot
218142
+ });
218143
+ } catch (err) {
218144
+ active.reviewState = "done";
217483
218145
  throw err;
217484
218146
  }
218147
+ if (!authorizedReviewTargetMatches(active, attemptId, authorized)) {
218148
+ active.reviewState = "done";
218149
+ throw new Error("control plane returned a mismatched formal-review target");
218150
+ }
218151
+ const effect = await this.githubReviewClient.submit(authorizedReviewTarget(active, attemptId, authorized, recovering), req);
218152
+ const result = this.persistGithubReviewEffect(active, attemptId, effect);
218153
+ try {
218154
+ await cp.reportGithubReviewResult({
218155
+ hookId: active.hook.hookId,
218156
+ deliveryKey: active.hook.deliveryKey,
218157
+ attemptId,
218158
+ snapshot: active.snapshot,
218159
+ result
218160
+ });
218161
+ if (effect.state === "not_submitted") {
218162
+ delete active.hook.reviewAttemptId;
218163
+ delete active.hook.reviewRequestedEvent;
218164
+ delete active.hook.reviewRequestedVerdict;
218165
+ delete active.hook.reviewResult;
218166
+ active.reviewState = "idle";
218167
+ this.persistHookState(entry);
218168
+ } else if (effect.state === "ambiguous") active.reviewState = "idle";
218169
+ else active.reviewState = "done";
218170
+ } catch (err) {
218171
+ active.reviewState = effect.state === "ambiguous" ? "idle" : "done";
218172
+ this.log.warn(`github review: immediate result report failed (${formatErr(err)})`);
218173
+ }
218174
+ return effect;
217485
218175
  }
217486
218176
  /** The op-switch behind {@link handleRelayMsg} (dedup handled by the caller). */
217487
218177
  dispatchRelayOp(msg, chat) {
@@ -218075,26 +218765,142 @@ var Daemon = class Daemon {
218075
218765
  * durable row would be un-replayable. Sets `entry.inboxId` so every terminal path can delete
218076
218766
  * the row. The row id is the message's stable deliveryId/msgId (§6.3) — idempotent re-append.
218077
218767
  */
218078
- persistInbox(entry, key) {
218079
- if (entry.webchat) return;
218768
+ persistInbox(entry, key, options = {}) {
218769
+ if (entry.webchat) return "skipped";
218080
218770
  const id = entry.callMeta?.deliveryId ?? entry.msg.msgId;
218081
218771
  try {
218082
- this.store.appendInbox({
218772
+ const inserted = this.store.appendInbox({
218083
218773
  id,
218084
218774
  sessionKey: key,
218085
218775
  agentId: entry.agentId,
218086
218776
  msg: JSON.stringify(entry.msg),
218087
218777
  integrationId: entry.integrationId ?? null,
218088
218778
  callMeta: entry.callMeta ? JSON.stringify(entry.callMeta) : null,
218779
+ hookContext: entry.hookContext ? JSON.stringify(entry.hookContext) : null,
218780
+ posterPublishState: entry.posterPublishState ?? null,
218089
218781
  isQueueCmd: entry.isQueueCmd ? 1 : null,
218090
218782
  loopGuardCounted: 1,
218091
218783
  enqueuedAt: monotonicTs()
218092
218784
  });
218785
+ if (!inserted && !options.adoptExisting) return "existing";
218093
218786
  entry.inboxId = id;
218094
218787
  this.liveInboxIds.add(id);
218788
+ return inserted ? "inserted" : "adopted";
218095
218789
  } catch (err) {
218790
+ if (options.required) throw err;
218096
218791
  this.log.warn(`durable inbox: append failed for ${id}: ${err.message}`);
218792
+ return "failed";
218793
+ }
218794
+ }
218795
+ persistHookState(entry, posterPublishState, required = false) {
218796
+ if (!entry.inboxId || !entry.hookContext) {
218797
+ if (required) throw new Error("hook state has no durable inbox row");
218798
+ return;
218799
+ }
218800
+ if (posterPublishState) entry.posterPublishState = posterPublishState;
218801
+ try {
218802
+ if (!this.store.updateInboxHookState(entry.inboxId, JSON.stringify(entry.hookContext), posterPublishState)) throw new Error("durable inbox row is missing");
218803
+ } catch (err) {
218804
+ if (required) throw err;
218805
+ this.log.warn(`durable inbox: hook state update failed for ${entry.inboxId}: ${err.message}`);
218806
+ }
218807
+ }
218808
+ emitHookCompletion(hook, status, extra = {}, owner) {
218809
+ if (owner?.hookTerminalReceipt) return;
218810
+ const start = Date.parse(hook.turnStartedAt ?? hook.firedAt);
218811
+ const reviewAttemptId = hook.reviewReportAttemptId ?? hook.reviewAttemptId;
218812
+ const reviewResult = hook.reviewReportResult ?? hook.reviewResult;
218813
+ const report = {
218814
+ hookId: hook.hookId,
218815
+ agentId: hook.agentId,
218816
+ deliveryKey: hook.deliveryKey,
218817
+ ...hook.snapshot ?? {},
218818
+ ...hook.event ? { event: hook.event } : {},
218819
+ ...hook.github ? { github: hook.github } : {},
218820
+ status,
218821
+ durationMs: Number.isFinite(start) ? Math.max(0, this.clock.now() - start) : 0,
218822
+ ...extra,
218823
+ ...reviewAttemptId && reviewResult ? {
218824
+ reviewAttemptId,
218825
+ reviewResult
218826
+ } : {}
218827
+ };
218828
+ let reportInboxId;
218829
+ if (owner?.inboxId) try {
218830
+ const completed = this.store.completeHookInbox(owner.inboxId, JSON.stringify(report), this.clock.now());
218831
+ if (completed === "already-terminal") {
218832
+ owner.hookTerminalReceipt = true;
218833
+ this.liveInboxIds.delete(owner.inboxId);
218834
+ return;
218835
+ }
218836
+ if (completed === "missing") {
218837
+ this.log.warn(`durable inbox: hook terminal receipt owner is missing for ${owner.inboxId}`);
218838
+ this.liveInboxIds.delete(owner.inboxId);
218839
+ } else {
218840
+ owner.hookTerminalReceipt = true;
218841
+ this.liveInboxIds.delete(owner.inboxId);
218842
+ reportInboxId = owner.inboxId;
218843
+ }
218844
+ } catch (err) {
218845
+ this.log.warn(`durable inbox: hook terminal receipt failed for ${owner.inboxId}: ${formatErr(err)}`);
218097
218846
  }
218847
+ this.sendHookReport(report, reportInboxId);
218848
+ }
218849
+ /** Send one durable terminal report and release only its outbox payload after
218850
+ * the CP's correlated persistence/projection ACK. The stable id receipt stays
218851
+ * bounded locally to absorb later relay redelivery. */
218852
+ sendHookReport(report, inboxId) {
218853
+ if (!this.cpClient) return;
218854
+ if (inboxId && this.hookReportInflight.has(inboxId)) return;
218855
+ if (inboxId && this.hookReportInflight.size >= MAX_HOOK_REPORT_INFLIGHT) {
218856
+ this.scheduleHookReportRetry();
218857
+ return;
218858
+ }
218859
+ if (inboxId) this.hookReportInflight.add(inboxId);
218860
+ const connectionId = this.hookReportConnectionId;
218861
+ let refillDrainSlot = false;
218862
+ Promise.resolve(this.cpClient.emitHookReport(report)).then((result) => {
218863
+ if (!inboxId) return;
218864
+ if (result === "legacy-sent") {
218865
+ try {
218866
+ if (!this.store.markLegacyHookReportSent(inboxId, connectionId)) throw new Error("durable inbox row is missing");
218867
+ refillDrainSlot = true;
218868
+ } catch (err) {
218869
+ this.log.warn(`durable inbox: legacy report stamp failed for ${inboxId}: ${formatErr(err)}`);
218870
+ this.scheduleHookReportRetry();
218871
+ }
218872
+ return;
218873
+ }
218874
+ refillDrainSlot = true;
218875
+ try {
218876
+ this.store.acknowledgeHookInbox(inboxId);
218877
+ } catch (err) {
218878
+ this.log.warn(`durable inbox: hook report ACK cleanup failed for ${inboxId}: ${formatErr(err)}`);
218879
+ }
218880
+ }).catch((err) => {
218881
+ if (typeof err === "object" && err !== null && "retryable" in err && err.retryable === false && inboxId) {
218882
+ refillDrainSlot = true;
218883
+ try {
218884
+ this.store.acknowledgeHookInbox(inboxId);
218885
+ } catch (cleanupError) {
218886
+ this.log.warn(`durable inbox: hook report dead-letter failed for ${inboxId}: ${formatErr(cleanupError)}`);
218887
+ }
218888
+ this.log.warn(`hook report permanently rejected for ${inboxId}: ${formatErr(err)}`);
218889
+ return;
218890
+ }
218891
+ this.log.debug(`hook report retained for retry: ${formatErr(err)}`);
218892
+ this.scheduleHookReportRetry();
218893
+ }).finally(() => {
218894
+ if (inboxId) this.hookReportInflight.delete(inboxId);
218895
+ if (refillDrainSlot) this.scheduleHookReportRetry(250);
218896
+ });
218897
+ }
218898
+ scheduleHookReportRetry(delayMs = 5e3) {
218899
+ if (this.draining || this.hookReportRetryTimer !== void 0) return;
218900
+ this.hookReportRetryTimer = this.clock.setTimeout(() => {
218901
+ this.hookReportRetryTimer = void 0;
218902
+ this.replayHookTerminalReports();
218903
+ }, delayMs);
218098
218904
  }
218099
218905
  /** Remove an entry's durable inbox row once its turn reaches ANY terminal state (success,
218100
218906
  * reject/fail-stop, cancel, gate-drop) so it is not replayed on the next startup. No-op
@@ -218103,6 +218909,7 @@ var Daemon = class Daemon {
218103
218909
  removeInbox(entry) {
218104
218910
  if (!entry.inboxId) return;
218105
218911
  this.liveInboxIds.delete(entry.inboxId);
218912
+ if (entry.hookContext) return;
218106
218913
  try {
218107
218914
  this.store.removeInbox(entry.inboxId);
218108
218915
  } catch (err) {
@@ -218130,6 +218937,7 @@ var Daemon = class Daemon {
218130
218937
  let removed = 0;
218131
218938
  try {
218132
218939
  for (const row of this.store.listInboxBySessionKeyFifo()) {
218940
+ if (row.hookContext || row.terminalReport) continue;
218133
218941
  let msg;
218134
218942
  try {
218135
218943
  msg = JSON.parse(row.msg);
@@ -218146,8 +218954,8 @@ var Daemon = class Daemon {
218146
218954
  }
218147
218955
  return removed;
218148
218956
  }
218149
- isLoopGuardOpen(msg) {
218150
- return usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopGuardScope(msg));
218957
+ isLoopGuardOpen(msg, includeHook = false) {
218958
+ return (usesLoopGuard(msg) || includeHook) && this.store.isLoopGuardOpen(loopGuardScope(msg));
218151
218959
  }
218152
218960
  /** First-open side effects for a durable conversation circuit: purge restart work,
218153
218961
  * drop every matching serial queue, cancel live ACP turns across ALL agents sharing
@@ -218214,41 +219022,71 @@ var Daemon = class Daemon {
218214
219022
  ...error ? { error } : {}
218215
219023
  });
218216
219024
  }
218217
- dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply) {
219025
+ dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply, hookContext, posterPublishState = githubReply ? "not_started" : void 0) {
218218
219026
  return new Promise((resolve, reject) => {
219027
+ let admissionSettled = false;
219028
+ const settleAdmission = (result) => {
219029
+ if (admissionSettled) return;
219030
+ admissionSettled = true;
219031
+ opts?.onAdmission?.(result);
219032
+ };
218219
219033
  if (this.draining || this.drainingAgents.has(agentId)) {
218220
219034
  this.log.debug(`dispatch: skipped for agent "${agentId}" (draining)`);
219035
+ settleAdmission({
219036
+ accepted: false,
219037
+ reason: "draining"
219038
+ });
218221
219039
  resolve(null);
218222
219040
  return;
218223
219041
  }
218224
219042
  if (this.paused(agentId)) {
218225
219043
  this.log.info(`dispatch: skipped for agent "${agentId}" (paused)`);
219044
+ settleAdmission({
219045
+ accepted: false,
219046
+ reason: "paused"
219047
+ });
218226
219048
  resolve(null);
218227
219049
  return;
218228
219050
  }
218229
219051
  if (this.safetyDrainingAgents.has(agentId)) {
218230
219052
  if (opts?.fromInboxReplay) this.waitForSafetyDrain(agentId).then(() => {
218231
219053
  if (this.draining || !this.agents.has(agentId)) {
219054
+ settleAdmission({
219055
+ accepted: false,
219056
+ reason: "draining"
219057
+ });
218232
219058
  resolve(null);
218233
219059
  return;
218234
219060
  }
218235
219061
  if (opts.inboxReplayId !== void 0 && !this.store.hasInbox(opts.inboxReplayId)) {
219062
+ settleAdmission({
219063
+ accepted: false,
219064
+ reason: "dropped"
219065
+ });
218236
219066
  resolve(null);
218237
219067
  return;
218238
219068
  }
218239
- this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply).then(resolve, reject);
219069
+ this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply, hookContext, posterPublishState).then(resolve, reject);
218240
219070
  }).catch(reject);
218241
219071
  else {
218242
219072
  this.log.info(`dispatch: skipped for agent "${agentId}" (stopping interrupted work)`);
219073
+ settleAdmission({
219074
+ accepted: false,
219075
+ reason: "busy"
219076
+ });
218243
219077
  resolve(null);
218244
219078
  }
218245
219079
  return;
218246
219080
  }
218247
219081
  const key = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, agentId);
218248
219082
  const loopScope = loopGuardScope(msg);
218249
- if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
219083
+ if ((usesLoopGuard(msg) || hookContext !== void 0) && this.store.isLoopGuardOpen(loopScope)) {
218250
219084
  if (opts?.fromInboxReplay) this.purgeLoopScopeInbox(loopScope);
218251
219085
  this.log.warn(`dispatch: skipped ${msg.msgId}; loop guard is open for ${loopScope}`);
219086
+ settleAdmission({
219087
+ accepted: false,
219088
+ reason: "loop_protection"
219089
+ });
218252
219090
  resolve(null);
218253
219091
  return;
218254
219092
  }
@@ -218262,6 +219100,10 @@ var Daemon = class Daemon {
218262
219100
  });
218263
219101
  else this.purgeLoopScopeInbox(loopScope);
218264
219102
  } else this.log.warn(`dispatch: dropped malformed Slack platform event ${msg.msgId}`);
219103
+ settleAdmission({
219104
+ accepted: false,
219105
+ reason: "malformed_platform_event"
219106
+ });
218265
219107
  resolve(null);
218266
219108
  return;
218267
219109
  }
@@ -218272,8 +219114,10 @@ var Daemon = class Daemon {
218272
219114
  ...integrationId !== void 0 ? { integrationId } : {},
218273
219115
  ...webchat ? { webchat } : {},
218274
219116
  ...callMeta ? { callMeta } : {},
219117
+ ...hookContext ? { hookContext } : {},
218275
219118
  ...opts?.isQueueCmd ? { isQueueCmd: true } : {},
218276
219119
  ...githubReply ? { githubReply } : {},
219120
+ ...posterPublishState ? { posterPublishState } : {},
218277
219121
  resolve,
218278
219122
  reject
218279
219123
  };
@@ -218281,25 +219125,81 @@ var Daemon = class Daemon {
218281
219125
  const q = this.serialQueue.get(key) ?? [];
218282
219126
  if (q.length >= MAX_QUEUED_PER_SESSION) {
218283
219127
  this.log.warn(`dispatch: queue full for session ${key} (${q.length}) — rejecting (queue_full)`);
219128
+ settleAdmission({
219129
+ accepted: false,
219130
+ reason: "queue_full"
219131
+ });
218284
219132
  reject(new QueueFullError(key));
218285
219133
  return;
218286
219134
  }
218287
219135
  if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
219136
+ settleAdmission({
219137
+ accepted: false,
219138
+ reason: "loop_protection"
219139
+ });
219140
+ resolve(null);
219141
+ return;
219142
+ }
219143
+ let persistence;
219144
+ try {
219145
+ persistence = this.persistInbox(entry, key, {
219146
+ required: opts?.requireDurable,
219147
+ adoptExisting: opts?.adoptExistingInbox
219148
+ });
219149
+ } catch (err) {
219150
+ settleAdmission({
219151
+ accepted: false,
219152
+ reason: "durability"
219153
+ });
219154
+ reject(err);
219155
+ return;
219156
+ }
219157
+ if (persistence === "existing") {
219158
+ settleAdmission({
219159
+ accepted: true,
219160
+ duplicate: true
219161
+ });
218288
219162
  resolve(null);
218289
219163
  return;
218290
219164
  }
218291
219165
  q.push(entry);
218292
219166
  this.serialQueue.set(key, q);
218293
- this.persistInbox(entry, key);
219167
+ settleAdmission({ accepted: true });
218294
219168
  this.log.debug(`dispatch: queued behind in-flight turn for session ${key} (depth ${q.length})`);
218295
219169
  return;
218296
219170
  }
218297
219171
  if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
219172
+ settleAdmission({
219173
+ accepted: false,
219174
+ reason: "loop_protection"
219175
+ });
219176
+ resolve(null);
219177
+ return;
219178
+ }
219179
+ let persistence;
219180
+ try {
219181
+ persistence = this.persistInbox(entry, key, {
219182
+ required: opts?.requireDurable,
219183
+ adoptExisting: opts?.adoptExistingInbox
219184
+ });
219185
+ } catch (err) {
219186
+ settleAdmission({
219187
+ accepted: false,
219188
+ reason: "durability"
219189
+ });
219190
+ reject(err);
219191
+ return;
219192
+ }
219193
+ if (persistence === "existing") {
219194
+ settleAdmission({
219195
+ accepted: true,
219196
+ duplicate: true
219197
+ });
218298
219198
  resolve(null);
218299
219199
  return;
218300
219200
  }
218301
- this.persistInbox(entry, key);
218302
219201
  this.inflight.add(key);
219202
+ settleAdmission({ accepted: true });
218303
219203
  this.runLoop(key, entry);
218304
219204
  });
218305
219205
  }
@@ -218355,7 +219255,7 @@ var Daemon = class Daemon {
218355
219255
  dispatchGateReason(entry) {
218356
219256
  if (entry.cancelledReason) return entry.cancelledReason;
218357
219257
  if (this.paused(entry.agentId)) return "pause";
218358
- if (this.isLoopGuardOpen(entry.msg)) return "loop protection";
219258
+ if (this.isLoopGuardOpen(entry.msg, entry.hookContext !== void 0)) return "loop protection";
218359
219259
  }
218360
219260
  beginActiveDispatch(agentId, key) {
218361
219261
  let resolveDone;
@@ -218406,13 +219306,19 @@ var Daemon = class Daemon {
218406
219306
  this.activeGateEntries.set(key, entry);
218407
219307
  if (entry !== firstEntry && (this.draining || this.drainingAgents.has(entry.agentId) || this.paused(entry.agentId))) {
218408
219308
  this.terminateQueuedSink(entry);
218409
- if (!this.draining) this.removeInbox(entry);
219309
+ if (!this.draining) {
219310
+ if (entry.hookContext) this.emitHookCompletion(entry.hookContext, "failed", { reason: "dropped" }, entry);
219311
+ this.removeInbox(entry);
219312
+ }
218410
219313
  entry.resolve(null);
218411
219314
  const rest = this.serialQueue.get(key) ?? [];
218412
219315
  this.serialQueue.delete(key);
218413
219316
  for (const e of rest) {
218414
219317
  this.terminateQueuedSink(e);
218415
- if (!this.draining) this.removeInbox(e);
219318
+ if (!this.draining) {
219319
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: "dropped" }, e);
219320
+ this.removeInbox(e);
219321
+ }
218416
219322
  e.resolve(null);
218417
219323
  }
218418
219324
  this.inflight.delete(key);
@@ -218436,7 +219342,10 @@ var Daemon = class Daemon {
218436
219342
  for (const e of rest) {
218437
219343
  const failStop = new FailStopError(key);
218438
219344
  this.terminateQueuedSink(e, failStop.message);
218439
- if (!this.draining) this.removeInbox(e);
219345
+ if (!this.draining) {
219346
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: "fail_stop" }, e);
219347
+ this.removeInbox(e);
219348
+ }
218440
219349
  e.reject(failStop);
218441
219350
  }
218442
219351
  this.inflight.delete(key);
@@ -218452,13 +219361,23 @@ var Daemon = class Daemon {
218452
219361
  }
218453
219362
  }
218454
219363
  async dispatchOne(entry, key) {
218455
- const { agentId, msg, integrationId, webchat, callMeta, githubReply } = entry;
219364
+ const { agentId, msg, integrationId, webchat, callMeta, githubReply, hookContext } = entry;
218456
219365
  const initialGate = this.dispatchGateReason(entry);
218457
219366
  if (initialGate) {
218458
219367
  this.terminateQueuedSink(entry);
219368
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: initialGate }, entry);
218459
219369
  this.log.info(`dispatch: skipped admitted turn ${msg.msgId} (${initialGate})`);
218460
219370
  return null;
218461
219371
  }
219372
+ if (hookContext && !hookContext.turnStartedAt) {
219373
+ hookContext.turnStartedAt = new Date(this.clock.now()).toISOString();
219374
+ try {
219375
+ this.persistHookState(entry, void 0, true);
219376
+ } catch (err) {
219377
+ this.emitHookCompletion(hookContext, "failed", { reason: "durability" }, entry);
219378
+ throw err;
219379
+ }
219380
+ }
218462
219381
  if (callMeta?.correlationId !== void 0) this.recordWorkerReport(key, callMeta, msg.text);
218463
219382
  const agent = this.agents.get(agentId);
218464
219383
  const agentName = agent.displayName?.trim() || agent.name;
@@ -218510,7 +219429,11 @@ var Daemon = class Daemon {
218510
219429
  } finally {
218511
219430
  releaseReplyConn();
218512
219431
  }
218513
- if (interrupted) return null;
219432
+ if (interrupted) {
219433
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: interrupted }, entry);
219434
+ return null;
219435
+ }
219436
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: "session_start_failed" }, entry);
218514
219437
  throw err;
218515
219438
  }
218516
219439
  const { sessionId, blocks, created } = handled;
@@ -218530,6 +219453,7 @@ var Daemon = class Daemon {
218530
219453
  this.showActivity(replyConn, msg.channel, statusThread, "");
218531
219454
  this.terminateQueuedSink(entry);
218532
219455
  releaseReplyConn();
219456
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: initializedGate }, entry);
218533
219457
  this.log.info(`dispatch: skipped initialized turn ${msg.msgId} (${initializedGate})`);
218534
219458
  return null;
218535
219459
  }
@@ -218570,13 +219494,17 @@ var Daemon = class Daemon {
218570
219494
  resolveDone,
218571
219495
  ...callMeta ? { callMeta } : {},
218572
219496
  ...pendingWebchat ? { webchat: pendingWebchat } : {},
218573
- ...githubReply ? { github: {
219497
+ ...githubReply && entry.posterPublishState !== "in_flight" && entry.posterPublishState !== "settled" ? { github: {
218574
219498
  ...this.makeGithubReply(agentId, githubReply, sessionId),
218575
219499
  deferredFinalTranscript: false
218576
219500
  } } : {}
218577
219501
  };
218578
219502
  this.pending.set(pendingTurnKey(agentId, sessionId), p);
218579
219503
  if (callMeta) this.activeTurnCallMeta.set(key, callMeta);
219504
+ const activeGithub = await this.prepareGithubTurn(entry).catch((err) => {
219505
+ this.log.warn(`github review: turn setup failed (${formatErr(err)})`);
219506
+ });
219507
+ if (activeGithub) this.activeGithubTurnMeta.set(key, activeGithub);
218580
219508
  let finalPhase = "end";
218581
219509
  try {
218582
219510
  const host = await this.ensureHostAsync(agentId);
@@ -218593,6 +219521,10 @@ var Daemon = class Daemon {
218593
219521
  this.showActivity(replyConn, msg.channel, statusThread, "");
218594
219522
  this.terminateQueuedSink(entry);
218595
219523
  if (readyGate === "loop protection") finalPhase = "problem";
219524
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219525
+ sessionId,
219526
+ reason: readyGate
219527
+ }, entry);
218596
219528
  this.log.info(`dispatch: skipped ready turn ${msg.msgId} (${readyGate})`);
218597
219529
  return null;
218598
219530
  }
@@ -218604,6 +219536,10 @@ var Daemon = class Daemon {
218604
219536
  const { stopReason, usage } = await host.prompt(sessionId, blocks);
218605
219537
  if (p.outputSuppressed) {
218606
219538
  if (p.outputSuppressed === "loop protection") finalPhase = "problem";
219539
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219540
+ sessionId,
219541
+ reason: p.outputSuppressed
219542
+ }, entry);
218607
219543
  return null;
218608
219544
  }
218609
219545
  if (usage) {
@@ -218658,6 +219594,10 @@ var Daemon = class Daemon {
218658
219594
  this.clearIdle(p);
218659
219595
  if (p.outputSuppressed) {
218660
219596
  if (p.outputSuppressed === "loop protection") finalPhase = "problem";
219597
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219598
+ sessionId,
219599
+ reason: p.outputSuppressed
219600
+ }, entry);
218661
219601
  return null;
218662
219602
  }
218663
219603
  finalPhase = "problem";
@@ -218695,6 +219635,10 @@ var Daemon = class Daemon {
218695
219635
  this.showActivity(replyConn, msg.channel, statusThread, "");
218696
219636
  await p.applyChain;
218697
219637
  }
219638
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219639
+ sessionId,
219640
+ reason: "turn_failed"
219641
+ }, entry);
218698
219642
  throw err;
218699
219643
  } finally {
218700
219644
  this.clearCancelBackstop(agentId, sessionId);
@@ -218704,6 +219648,7 @@ var Daemon = class Daemon {
218704
219648
  this.releasePermissions(agentId, sessionId);
218705
219649
  this.releaseElicits(agentId, sessionId);
218706
219650
  if (callMeta) this.activeTurnCallMeta.delete(key);
219651
+ if (activeGithub && this.activeGithubTurnMeta.get(key) === activeGithub) this.activeGithubTurnMeta.delete(key);
218707
219652
  const githubFinal = p.github && !p.outputSuppressed && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
218708
219653
  if (p.github?.deferredFinalTranscript && githubFinal?.trim()) this.store.appendTranscript({
218709
219654
  channel: p.channel,
@@ -218713,7 +219658,19 @@ var Daemon = class Daemon {
218713
219658
  kind: "text",
218714
219659
  text: githubFinal
218715
219660
  });
218716
- if (p.github && !p.outputSuppressed) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
219661
+ if (p.github && !p.outputSuppressed) {
219662
+ let recordedInFlight = false;
219663
+ try {
219664
+ this.persistHookState(entry, "in_flight", true);
219665
+ recordedInFlight = true;
219666
+ } catch (err) {
219667
+ this.log.warn(`github poster: durability barrier failed; final publish skipped (${formatErr(err)})`);
219668
+ }
219669
+ if (recordedInFlight) {
219670
+ await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
219671
+ this.persistHookState(entry, "settled");
219672
+ }
219673
+ }
218717
219674
  this.pending.delete(pendingTurnKey(agentId, sessionId));
218718
219675
  this.store.setSessionState(key, "idle", this.clock.now());
218719
219676
  this.emitSessionMetadataSnapshot({
@@ -218726,6 +219683,7 @@ var Daemon = class Daemon {
218726
219683
  });
218727
219684
  p.resolveDone();
218728
219685
  }
219686
+ if (hookContext) this.emitHookCompletion(hookContext, "success", { sessionId }, entry);
218729
219687
  return sessionId;
218730
219688
  }
218731
219689
  /** Interrupt a session by its LOGICAL sessionKey (shared by pause, `!stop`, `!cancel`,
@@ -218747,6 +219705,7 @@ var Daemon = class Daemon {
218747
219705
  this.serialQueue.delete(key);
218748
219706
  for (const e of queued) {
218749
219707
  this.terminateQueuedSink(e, opts.dropQueued ? void 0 : reason);
219708
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: opts.dropQueued ? "dropped" : "cancelled" }, e);
218750
219709
  this.removeInbox(e);
218751
219710
  if (opts.dropQueued) e.resolve(null);
218752
219711
  else e.reject(new FailStopError(key));
@@ -220139,49 +221098,92 @@ var Daemon = class Daemon {
220139
221098
  const purgedPausedAgents = /* @__PURE__ */ new Set();
220140
221099
  const purgedLoopScopes = /* @__PURE__ */ new Set();
220141
221100
  for (const row of rows) {
221101
+ if (row.completedAt !== null && row.completedAt !== void 0) continue;
220142
221102
  if (this.liveInboxIds.has(row.id)) continue;
220143
221103
  if (!this.agents.has(row.agentId)) {
220144
221104
  this.log.warn(`durable inbox: skipping replay of ${row.id} — unknown agent "${row.agentId}"`);
220145
221105
  continue;
220146
221106
  }
220147
- if (this.paused(row.agentId)) {
220148
- if (!purgedPausedAgents.has(row.agentId)) {
220149
- purgedPausedAgents.add(row.agentId);
220150
- this.purgeAgentInbox(row.agentId);
220151
- }
220152
- continue;
220153
- }
220154
221107
  let msg;
220155
221108
  let callMeta;
221109
+ let hookContext;
220156
221110
  try {
220157
221111
  msg = JSON.parse(row.msg);
220158
221112
  callMeta = row.callMeta ? JSON.parse(row.callMeta) : void 0;
221113
+ hookContext = row.hookContext ? JSON.parse(row.hookContext) : void 0;
220159
221114
  } catch (err) {
220160
221115
  this.log.warn(`durable inbox: skipping corrupt row ${row.id}: ${err.message}`);
220161
221116
  continue;
220162
221117
  }
221118
+ if (msg.source === "hook" && !hookContext) {
221119
+ this.log.warn(`durable inbox: tombstoning legacy hook row ${row.id} without trusted context`);
221120
+ this.store.removeInbox(row.id);
221121
+ continue;
221122
+ }
221123
+ if (this.paused(row.agentId)) {
221124
+ if (!purgedPausedAgents.has(row.agentId)) {
221125
+ purgedPausedAgents.add(row.agentId);
221126
+ this.purgeAgentInbox(row.agentId);
221127
+ }
221128
+ if (hookContext) this.emitHookCompletion(hookContext, "failed", { reason: "pause" }, { inboxId: row.id });
221129
+ continue;
221130
+ }
220163
221131
  if (isMalformedPlatformTurn(msg) && !msg.isDm) {
220164
221132
  this.store.removeInbox(row.id);
220165
221133
  continue;
220166
221134
  }
220167
221135
  const loopScope = loopGuardScope(msg);
220168
- if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
221136
+ if ((usesLoopGuard(msg) || hookContext !== void 0) && this.store.isLoopGuardOpen(loopScope)) {
220169
221137
  if (!purgedLoopScopes.has(loopScope)) {
220170
221138
  purgedLoopScopes.add(loopScope);
220171
221139
  this.purgeLoopScopeInbox(loopScope);
220172
221140
  }
221141
+ if (hookContext) this.emitHookCompletion(hookContext, "failed", { reason: "loop protection" }, { inboxId: row.id });
220173
221142
  continue;
220174
221143
  }
221144
+ const posterPublishState = row.posterPublishState === "in_flight" || row.posterPublishState === "settled" ? row.posterPublishState : hookContext?.githubReply ? "not_started" : void 0;
220175
221145
  this.dispatch(row.agentId, msg, row.integrationId ?? void 0, void 0, callMeta, {
220176
221146
  ...row.isQueueCmd ? { isQueueCmd: true } : {},
220177
221147
  fromInboxReplay: true,
220178
221148
  inboxReplayId: row.id,
220179
- replay: row.loopGuardCounted === 1
220180
- }).catch(() => {});
221149
+ replay: row.loopGuardCounted === 1,
221150
+ adoptExistingInbox: true,
221151
+ ...hookContext ? { requireDurable: true } : {}
221152
+ }, hookContext?.githubReply, hookContext, posterPublishState).catch(() => {});
220181
221153
  replayed++;
220182
221154
  }
220183
221155
  if (replayed) this.log.info(`durable inbox: replayed ${replayed} admitted message(s) through the serial gate`);
220184
221156
  }
221157
+ /** Re-assert retained metadata-only hook completions whenever the CP socket
221158
+ * becomes ready. HookRepo applies them idempotently; keeping the receipt also
221159
+ * closes the completed-before-restart relay redelivery window. */
221160
+ replayHookTerminalReports() {
221161
+ let rows;
221162
+ try {
221163
+ rows = this.store.listInboxBySessionKeyFifo();
221164
+ } catch (err) {
221165
+ this.log.warn(`durable inbox: terminal report read failed: ${formatErr(err)}`);
221166
+ this.scheduleHookReportRetry();
221167
+ return;
221168
+ }
221169
+ const pending = rows.filter((row) => row.terminalReport && row.legacyReportConnection !== this.hookReportConnectionId && !this.hookReportInflight.has(row.id));
221170
+ const available = Math.max(0, MAX_HOOK_REPORT_INFLIGHT - this.hookReportInflight.size);
221171
+ const batch = pending.slice(0, available);
221172
+ let emitted = 0;
221173
+ for (const row of batch) try {
221174
+ const decoded = HookReport.parse(JSON.parse(row.terminalReport));
221175
+ this.sendHookReport(decoded, row.id);
221176
+ emitted += 1;
221177
+ } catch (err) {
221178
+ this.log.warn(`durable inbox: corrupt terminal hook receipt ${row.id}: ${formatErr(err)}`);
221179
+ try {
221180
+ this.store.acknowledgeHookInbox(row.id);
221181
+ } catch (cleanupError) {
221182
+ this.log.warn(`durable inbox: corrupt receipt cleanup failed for ${row.id}: ${formatErr(cleanupError)}`);
221183
+ }
221184
+ }
221185
+ if (emitted) this.log.info(`durable inbox: re-emitted ${emitted} terminal hook report(s)`);
221186
+ }
220185
221187
  /** daemon/restart + daemon/upgrade (§8.3): ack now, then drain + stop + exit so
220186
221188
  * the supervisor relaunches (the new binary, for upgrade). */
220187
221189
  scheduleFleetExit(kind, targetVersion) {
@@ -220438,7 +221440,7 @@ var Daemon = class Daemon {
220438
221440
  * still runs; a `headless` msg additionally suppresses all platform output
220439
221441
  * in dispatch. Resolves with dispatch's sessionId (null = gate-dropped).
220440
221442
  */
220441
- async fireTrigger(agentId, msg, target, anchorText, label, githubReply) {
221443
+ async anchorTrigger(agentId, msg, target, anchorText, label) {
220442
221444
  if (this.draining || this.drainingAgents.has(agentId) || this.paused(agentId) || this.safetyDrainingAgents.has(agentId)) {
220443
221445
  this.log.info(`${label}: skipped for agent "${agentId}" (paused or draining)`);
220444
221446
  return null;
@@ -220456,7 +221458,12 @@ var Daemon = class Daemon {
220456
221458
  this.log.warn(`${label}: failed to post trigger to ${target.channel} (${formatErr(err)}) — running without anchor`);
220457
221459
  }
220458
221460
  }
220459
- return this.dispatch(agentId, msg, target?.integrationId, void 0, void 0, void 0, githubReply);
221461
+ return msg;
221462
+ }
221463
+ async fireTrigger(agentId, msg, target, anchorText, label, githubReply, hookContext) {
221464
+ const anchored = await this.anchorTrigger(agentId, msg, target, anchorText, label);
221465
+ if (!anchored) return null;
221466
+ return this.dispatch(agentId, anchored, target?.integrationId, void 0, void 0, void 0, githubReply, hookContext);
220460
221467
  }
220461
221468
  /** Build the per-turn GitHub final-answer selector and poster, tokened
220462
221469
  * via the repo-targeted gitcred mint (issues/PR write, no contents — never
@@ -220590,6 +221597,8 @@ var Daemon = class Daemon {
220590
221597
  mcpServerFacts: () => this.mcpServerFacts,
220591
221598
  onReady: () => {
220592
221599
  this.probeRuntimesAndEmit();
221600
+ this.hookReportConnectionId = randomUUID();
221601
+ this.replayHookTerminalReports();
220593
221602
  for (const [integrationId, channels] of this.channelSnapshots) this.cpClient?.emitIntegrationChannels({
220594
221603
  integrationId,
220595
221604
  channels
@@ -220711,6 +221720,10 @@ var Daemon = class Daemon {
220711
221720
  this.coldCancelTimers.clear();
220712
221721
  for (const t of this.slackRetryTimers.values()) this.clock.clearTimeout(t);
220713
221722
  this.slackRetryTimers.clear();
221723
+ if (this.hookReportRetryTimer !== void 0) {
221724
+ this.clock.clearTimeout(this.hookReportRetryTimer);
221725
+ this.hookReportRetryTimer = void 0;
221726
+ }
220714
221727
  for (const t of this.orchestrationDeadlines.values()) this.clock.clearTimeout(t);
220715
221728
  this.orchestrationDeadlines.clear();
220716
221729
  this.metrics?.stop();
@@ -220772,4 +221785,4 @@ async function runForeground(opts, deps = {}) {
220772
221785
  //#endregion
220773
221786
  export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
220774
221787
 
220775
- //# sourceMappingURL=run-foreground-B5Kfk1KO.js.map
221788
+ //# sourceMappingURL=run-foreground-BLhVJZlp.js.map