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

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) => {
@@ -68307,47 +68478,26 @@ function buildElicitationResolvedCard(params, decision) {
68307
68478
  }
68308
68479
  }];
68309
68480
  }
68310
- /** Build the compact in-thread status message: a live status `section`, followed by a
68311
- * small controls row. Slack mrkdwn links can only open URLs, not dispatch block_actions,
68312
- * so Configure/Cancel are real Block Kit buttons; View Session remains a URL button. */
68481
+ /** Build the compact in-thread status message as one Slack row: status text with an
68482
+ * inline View Session URL, plus one Configure accessory button. Slack sections allow
68483
+ * only one accessory, and mrkdwn links cannot dispatch block_actions, so Cancel stays in
68484
+ * the Configure modal rather than becoming a non-functional inline link. */
68313
68485
  function buildStatusBlocks(info, sessionKey, link) {
68314
- const elements = [];
68315
- if (link) elements.push({
68316
- type: "button",
68317
- action_id: STATUS_ACTION.view,
68318
- text: {
68319
- type: "plain_text",
68320
- text: "View Session"
68321
- },
68322
- url: link
68323
- });
68324
- elements.push({
68325
- type: "button",
68326
- action_id: STATUS_ACTION.manage,
68327
- text: {
68328
- type: "plain_text",
68329
- text: "Configure"
68330
- },
68331
- value: sessionKey
68332
- }, {
68333
- type: "button",
68334
- action_id: STATUS_ACTION.cancel,
68335
- text: {
68336
- type: "plain_text",
68337
- text: "Cancel"
68338
- },
68339
- style: "danger",
68340
- value: sessionKey
68341
- });
68342
68486
  return [{
68343
68487
  type: "section",
68344
68488
  text: {
68345
68489
  type: "mrkdwn",
68346
- text: renderStatusBar(info)
68490
+ text: link ? `${renderStatusBar(info)} · <${link}|View Session>` : renderStatusBar(info)
68491
+ },
68492
+ accessory: {
68493
+ type: "button",
68494
+ action_id: STATUS_ACTION.manage,
68495
+ text: {
68496
+ type: "plain_text",
68497
+ text: "Configure"
68498
+ },
68499
+ value: sessionKey
68347
68500
  }
68348
- }, {
68349
- type: "actions",
68350
- elements
68351
68501
  }];
68352
68502
  }
68353
68503
  /** Format a token count with thousands separators for the modal's usage grid. */
@@ -197666,7 +197816,10 @@ const UNTRUSTED_CONTENT_BEGIN = "----- BEGIN UNTRUSTED EXTERNAL CONTENT (GitHub
197666
197816
  const UNTRUSTED_CONTENT_END = "----- END UNTRUSTED EXTERNAL CONTENT -----";
197667
197817
  /** channel/thread from the affinity key — see the sessionKey grammar on {@link RdMsgHook}. */
197668
197818
  function splitSessionKey(msg) {
197669
- if (msg.sessionKey === msg.hookId) return { channel: msg.hookId };
197819
+ if (msg.sessionKey === msg.hookId) return {
197820
+ channel: msg.hookId,
197821
+ thread: msg.hookId
197822
+ };
197670
197823
  if (msg.sessionKey === `${msg.hookId}:${msg.deliveryKey}`) return {
197671
197824
  channel: msg.hookId,
197672
197825
  thread: msg.deliveryKey
@@ -197731,7 +197884,7 @@ function neutralizeDelimiters(body) {
197731
197884
  * Only emitted when the poster will actually run (a thread number exists); push
197732
197885
  * fires have no thread, so they never see this. */
197733
197886
  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");
197887
+ 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
197888
  }
197736
197889
  /** The github-kind turn text: a trusted metadata header + the FENCED excerpt.
197737
197890
  * The title rides the header (relay-sanitized to one capped line) — it is
@@ -198125,6 +198278,227 @@ var GithubFinalPoster = class {
198125
198278
  }
198126
198279
  };
198127
198280
  //#endregion
198281
+ //#region src/github/review.ts
198282
+ const DEFAULT_TIMEOUT_MS$1 = 15e3;
198283
+ const MAX_REVIEW_PAGES = 10;
198284
+ const REVIEWS_PER_PAGE = 100;
198285
+ var GithubHttpError = class extends Error {
198286
+ status;
198287
+ constructor(status, message) {
198288
+ super(message);
198289
+ this.status = status;
198290
+ this.name = "GithubHttpError";
198291
+ }
198292
+ };
198293
+ function markerFor(target) {
198294
+ return `<!-- agentconnect-review:${Buffer.from(JSON.stringify({
198295
+ v: 1,
198296
+ hookId: target.hookId,
198297
+ deliveryKey: target.deliveryKey,
198298
+ attemptId: target.attemptId,
198299
+ headSha: target.expectedHeadSha
198300
+ }), "utf8").toString("base64url")} -->`;
198301
+ }
198302
+ function validateInput(input) {
198303
+ if (input.event === "APPROVE" && input.verdict !== "pass") return "APPROVE requires verdict=pass";
198304
+ if (input.event === "REQUEST_CHANGES" && input.verdict !== "fail") return "REQUEST_CHANGES requires verdict=fail";
198305
+ if ((input.event === "COMMENT" || input.event === "REQUEST_CHANGES") && !input.body.trim()) return `${input.event} requires a non-empty body`;
198306
+ for (const [index, comment] of (input.comments ?? []).entries()) {
198307
+ if (!comment.path.trim()) return `comments[${index}].path is required`;
198308
+ if (!comment.body.trim()) return `comments[${index}].body is required`;
198309
+ if (!Number.isInteger(comment.line) || comment.line <= 0) return `comments[${index}].line must be positive`;
198310
+ if (comment.startLine !== void 0) {
198311
+ if (!Number.isInteger(comment.startLine) || comment.startLine <= 0 || comment.startLine > comment.line) return `comments[${index}].startLine must be positive and no greater than line`;
198312
+ if (!comment.startSide) return `comments[${index}].startSide is required with startLine`;
198313
+ }
198314
+ }
198315
+ }
198316
+ function safeJson(text) {
198317
+ if (!text) return void 0;
198318
+ return JSON.parse(text.replace(/"id"\s*:\s*(\d{15,})/g, "\"id\":\"$1\""));
198319
+ }
198320
+ /** One lexical-scope client; it never caches tokens or targets. */
198321
+ var GithubReviewClient = class {
198322
+ fetchImpl;
198323
+ baseUrl;
198324
+ timeoutMs;
198325
+ constructor(deps = {}) {
198326
+ this.fetchImpl = deps.fetchImpl ?? fetch;
198327
+ this.baseUrl = (deps.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
198328
+ this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
198329
+ }
198330
+ async getPull(token, repoFullName, pullNumber) {
198331
+ const pull = await this.request(`/repos/${repoFullName}/pulls/${pullNumber}`, token, "GET");
198332
+ const headSha = pull.head?.sha;
198333
+ const baseSha = pull.base?.sha;
198334
+ if (!headSha || !baseSha) throw new GithubHttpError(502, "GitHub pull response omitted head/base SHA");
198335
+ return {
198336
+ headSha,
198337
+ baseSha,
198338
+ draft: pull.draft === true,
198339
+ state: pull.state ?? "unknown",
198340
+ merged: pull.merged === true
198341
+ };
198342
+ }
198343
+ async submit(target, input) {
198344
+ const invalid = validateInput(input);
198345
+ if (invalid) return {
198346
+ state: "not_submitted",
198347
+ code: "invalid_input",
198348
+ message: invalid
198349
+ };
198350
+ const marker = markerFor(target);
198351
+ try {
198352
+ let existing;
198353
+ try {
198354
+ existing = await this.findByMarker(target, marker);
198355
+ } catch (err) {
198356
+ if (target.recovering) return {
198357
+ state: "ambiguous",
198358
+ code: "ambiguous_write",
198359
+ message: "cannot reconcile the prior formal review attempt; automatic retry is blocked"
198360
+ };
198361
+ throw err;
198362
+ }
198363
+ if (existing) return this.submitted(existing, target, input);
198364
+ const pull = await this.getPull(target.token, target.repoFullName, target.pullNumber);
198365
+ if (pull.state !== "open" || pull.merged || pull.draft) return {
198366
+ state: "not_submitted",
198367
+ code: "pull_unavailable",
198368
+ message: `pull request is ${pull.merged ? "merged" : pull.draft ? "draft" : pull.state}`
198369
+ };
198370
+ if (pull.headSha !== target.expectedHeadSha || pull.baseSha !== target.expectedBaseSha) return {
198371
+ state: "not_submitted",
198372
+ code: "revision_changed",
198373
+ message: "pull request head/base changed while the review was running"
198374
+ };
198375
+ const body = input.body.trim() ? `${input.body}\n\n${marker}` : marker;
198376
+ try {
198377
+ const created = await this.request(`/repos/${target.repoFullName}/pulls/${target.pullNumber}/reviews`, target.token, "POST", {
198378
+ commit_id: target.expectedHeadSha,
198379
+ event: input.event,
198380
+ body,
198381
+ ...input.comments?.length ? { comments: input.comments.map((comment) => ({
198382
+ path: comment.path,
198383
+ body: comment.body,
198384
+ line: comment.line,
198385
+ side: comment.side,
198386
+ ...comment.startLine !== void 0 ? { start_line: comment.startLine } : {},
198387
+ ...comment.startSide !== void 0 ? { start_side: comment.startSide } : {}
198388
+ })) } : {}
198389
+ });
198390
+ return this.submitted(created, target, input);
198391
+ } catch (err) {
198392
+ if (err instanceof GithubHttpError && err.status >= 400 && err.status < 500) return {
198393
+ state: "not_submitted",
198394
+ code: "github_rejected",
198395
+ message: err.message
198396
+ };
198397
+ const recovered = await this.findByMarker(target, marker).catch(() => void 0);
198398
+ if (recovered) return this.submitted(recovered, target, input);
198399
+ return {
198400
+ state: "ambiguous",
198401
+ code: "ambiguous_write",
198402
+ message: "GitHub review outcome is unknown; automatic retry is blocked"
198403
+ };
198404
+ }
198405
+ } catch (err) {
198406
+ return {
198407
+ state: "not_submitted",
198408
+ code: "github_rejected",
198409
+ message: err instanceof Error ? err.message : String(err)
198410
+ };
198411
+ }
198412
+ }
198413
+ /** Read-only recovery for an attempt whose POST outcome was ambiguous. This
198414
+ * is safe to run automatically before a replayed model turn: it can converge
198415
+ * a now-visible marker, but never issues a second review mutation. */
198416
+ async reconcile(target, event, verdict) {
198417
+ const marker = markerFor(target);
198418
+ try {
198419
+ const existing = await this.findByMarker(target, marker);
198420
+ if (existing) return this.submitted(existing, target, {
198421
+ event,
198422
+ verdict,
198423
+ body: ""
198424
+ });
198425
+ return {
198426
+ state: "ambiguous",
198427
+ code: "ambiguous_write",
198428
+ message: "the prior formal review marker is not visible yet; automatic mutation retry is blocked"
198429
+ };
198430
+ } catch {
198431
+ return {
198432
+ state: "ambiguous",
198433
+ code: "ambiguous_write",
198434
+ message: "cannot reconcile the prior formal review attempt; automatic mutation retry is blocked"
198435
+ };
198436
+ }
198437
+ }
198438
+ submitted(review, target, input) {
198439
+ if (review.id === void 0 || review.id === null) return {
198440
+ state: "ambiguous",
198441
+ code: "ambiguous_write",
198442
+ message: "GitHub created/recovered a review without an id"
198443
+ };
198444
+ if (!review.commit_id) return {
198445
+ state: "ambiguous",
198446
+ code: "ambiguous_write",
198447
+ message: "GitHub created/recovered a review without its commit id"
198448
+ };
198449
+ const commitId = review.commit_id;
198450
+ if (commitId !== target.expectedHeadSha) return {
198451
+ state: "ambiguous",
198452
+ code: "ambiguous_write",
198453
+ message: "recovered review is anchored to an unexpected commit"
198454
+ };
198455
+ return {
198456
+ state: "submitted",
198457
+ reviewId: String(review.id),
198458
+ event: input.event,
198459
+ verdict: input.verdict,
198460
+ commitId
198461
+ };
198462
+ }
198463
+ async findByMarker(target, marker) {
198464
+ for (let page = 1; page <= MAX_REVIEW_PAGES; page += 1) {
198465
+ const rows = await this.request(`/repos/${target.repoFullName}/pulls/${target.pullNumber}/reviews?per_page=${REVIEWS_PER_PAGE}&page=${page}`, target.token, "GET");
198466
+ const hit = rows.find((review) => review.body?.includes(marker));
198467
+ if (hit) return hit;
198468
+ if (rows.length < REVIEWS_PER_PAGE) return void 0;
198469
+ }
198470
+ throw new Error("review marker reconciliation exceeded pagination bound");
198471
+ }
198472
+ async request(path, token, method, body) {
198473
+ let response;
198474
+ try {
198475
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
198476
+ method,
198477
+ headers: {
198478
+ authorization: `Bearer ${token}`,
198479
+ accept: "application/vnd.github+json",
198480
+ "x-github-api-version": "2022-11-28",
198481
+ ...body === void 0 ? {} : { "content-type": "application/json" }
198482
+ },
198483
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
198484
+ signal: AbortSignal.timeout(this.timeoutMs)
198485
+ });
198486
+ } catch (err) {
198487
+ throw new Error(`GitHub request failed: ${err instanceof Error ? err.message : String(err)}`);
198488
+ }
198489
+ const text = await response.text();
198490
+ if (!response.ok) {
198491
+ let detail = "";
198492
+ try {
198493
+ const parsed = safeJson(text);
198494
+ detail = parsed?.message ? `: ${parsed.message}` : "";
198495
+ } catch {}
198496
+ throw new GithubHttpError(response.status, `GitHub ${method} ${response.status}${detail}`);
198497
+ }
198498
+ return safeJson(text);
198499
+ }
198500
+ };
198501
+ //#endregion
198128
198502
  //#region src/runtimes/runtime-prober.ts
198129
198503
  const DEFAULT_TIMEOUT_MS = 3e4;
198130
198504
  const DEFAULT_CONCURRENCY = 3;
@@ -198358,7 +198732,7 @@ var RelayClient = class {
198358
198732
  const frame = decoded.frame;
198359
198733
  if (frame.corr && this.correlator.settle(frame)) return;
198360
198734
  if (frame.type === "rd/msg") {
198361
- this.handleMsg(frame.id, frame.payload);
198735
+ await this.handleMsg(frame.id, frame.payload);
198362
198736
  return;
198363
198737
  }
198364
198738
  if (frame.type === "rd/agentmsg/fwd") {
@@ -198370,9 +198744,9 @@ var RelayClient = class {
198370
198744
  /** An inbound rd/msg: dispatch it, reply `rd/ack`; only a webchat op streams
198371
198745
  * `rd/chat` back over this socket. A shared-bot `im` replies out-of-band via the
198372
198746
  * daemon's own Slack send, and a hook fire has no reply stream — both get a noop. */
198373
- handleMsg(reqId, msg) {
198747
+ async handleMsg(reqId, msg) {
198374
198748
  const chat = msg.source === "webchat" ? (event) => this.sendChat(msg.chatId, event) : () => void 0;
198375
- const ack = this.deps.onRelayMsg(msg, chat);
198749
+ const ack = await this.deps.onRelayMsg(msg, chat);
198376
198750
  this.transport?.send(JSON.stringify(buildRelayDaemonFrame("rd/ack", ack, { corr: reqId })));
198377
198751
  }
198378
198752
  /** A forwarded cross-daemon agent-call: terminal-verify + dispatch, reply the typed
@@ -215511,10 +215885,35 @@ function isBuiltinSystemTool(params) {
215511
215885
  tc?.toolCallId
215512
215886
  ].some((id) => typeof id === "string" && [...BUILTIN_TOOL_FQNS].some((fqn) => id.includes(fqn)));
215513
215887
  }
215888
+ function hookSnapshot(msg) {
215889
+ 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;
215890
+ return {
215891
+ configRevision: msg.configRevision,
215892
+ dispatchRevision: msg.dispatchRevision,
215893
+ dispatchDaemonId: msg.dispatchDaemonId,
215894
+ reviewPolicy: msg.reviewPolicy,
215895
+ reportingMode: msg.reportingMode,
215896
+ gateMode: msg.gateMode
215897
+ };
215898
+ }
215899
+ function reviewPolicyAllows(policy, event) {
215900
+ if (policy === "full") return true;
215901
+ if (policy === "request_changes") return event === "COMMENT" || event === "REQUEST_CHANGES";
215902
+ if (policy === "comment") return event === "COMMENT";
215903
+ return false;
215904
+ }
215905
+ function reviewResultForWire(effect) {
215906
+ if (effect.state === "submitted") return effect;
215907
+ return {
215908
+ state: effect.state,
215909
+ code: effect.code
215910
+ };
215911
+ }
215514
215912
  const MAX_QUEUED_PER_SESSION = 10;
215515
215913
  const LOOP_GUARD_WINDOW_MS = 6e4;
215516
215914
  const MAX_AUTOMATIC_TURNS_PER_WINDOW = 8;
215517
215915
  const MAX_TOTAL_TURNS_PER_WINDOW = 60;
215916
+ const MAX_HOOK_REPORT_INFLIGHT = 100;
215518
215917
  /** Thrown to a `dispatch()` caller when the per-session admission queue is at its depth
215519
215918
  * cap (§4.4 backpressure): the message is fast-failed, not buffered. Carries a stable
215520
215919
  * `reason` so an agent-call source (P1/P2) can surface a typed `queue_full`. */
@@ -215610,6 +216009,22 @@ function usesLoopGuard(msg) {
215610
216009
  function isMalformedPlatformTurn(msg) {
215611
216010
  return msg.platform === "slack" && msg.source === "user" && !msg.sender.isBot && msg.sender.id === "unknown" && msg.text.trim() === "" && (msg.attachments?.length ?? 0) === 0;
215612
216011
  }
216012
+ function authorizedReviewTargetMatches(active, attemptId, authorized) {
216013
+ 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;
216014
+ }
216015
+ function authorizedReviewTarget(active, attemptId, authorized, recovering = false) {
216016
+ return {
216017
+ token: authorized.token,
216018
+ repoFullName: authorized.repoFullName,
216019
+ pullNumber: authorized.pullNumber,
216020
+ expectedHeadSha: authorized.expectedHeadSha,
216021
+ expectedBaseSha: authorized.expectedBaseSha,
216022
+ hookId: active.hook.hookId,
216023
+ deliveryKey: active.hook.deliveryKey,
216024
+ attemptId,
216025
+ ...recovering ? { recovering: true } : {}
216026
+ };
216027
+ }
215613
216028
  function isSlackStatusBarText(text) {
215614
216029
  return text.startsWith(":bar_chart:") || text.startsWith("📊");
215615
216030
  }
@@ -215694,6 +216109,10 @@ var Daemon = class Daemon {
215694
216109
  botUserIds = {};
215695
216110
  cpRouting;
215696
216111
  cpCollab = new CpCollabRoutes();
216112
+ /** One active PR hook turn per logical session key. Long-lived ACP sessions
216113
+ * may span deliveries, so authorization must never live in SessionContext. */
216114
+ activeGithubTurnMeta = /* @__PURE__ */ new Map();
216115
+ githubReviewClient = new GithubReviewClient();
215697
216116
  clock;
215698
216117
  requestExit;
215699
216118
  draining = false;
@@ -215717,6 +216136,9 @@ var Daemon = class Daemon {
215717
216136
  cancelTimers = /* @__PURE__ */ new Map();
215718
216137
  coldCancelTimers = /* @__PURE__ */ new Map();
215719
216138
  slackRetryTimers = /* @__PURE__ */ new Map();
216139
+ hookReportRetryTimer;
216140
+ hookReportInflight = /* @__PURE__ */ new Set();
216141
+ hookReportConnectionId = randomUUID();
215720
216142
  slackRetryRuns = /* @__PURE__ */ new Map();
215721
216143
  constructor(opts = {}) {
215722
216144
  this.opts = opts;
@@ -215846,6 +216268,7 @@ var Daemon = class Daemon {
215846
216268
  startOrchestration: (req) => this.startOrchestration(req),
215847
216269
  getOrchestration: (req) => Promise.resolve(this.getOrchestrationForOwner(req)),
215848
216270
  cancelOrchestration: (req) => Promise.resolve(this.cancelOrchestrationForOwner(req)),
216271
+ submitGithubReview: (req) => this.submitGithubReview(req),
215849
216272
  memory: this.memory,
215850
216273
  recordOutbound: (ctx, channel, thread, text, ts) => this.store.appendTranscript({
215851
216274
  channel,
@@ -215867,6 +216290,7 @@ var Daemon = class Daemon {
215867
216290
  mcpServersFor: ({ agent, platform, channel, thread, integrationId, isDm }) => {
215868
216291
  const servers = [];
215869
216292
  let tools = toolsForIntegrations(agent.integrations);
216293
+ tools = [...tools, ...GITHUB_REVIEW_TOOLS];
215870
216294
  if (memoryKindOf(agent) === "native") tools = tools.filter((t) => !MEMORY_TOOL_NAMES.has(t.name));
215871
216295
  if (tools.length > 0) {
215872
216296
  const token = this.mcp.register({
@@ -216874,6 +217298,10 @@ var Daemon = class Daemon {
216874
217298
  this.log.debug(`webchat cancel: no in-flight turn for conversation ${conversationId}`);
216875
217299
  }
216876
217300
  relayMsgAcks = /* @__PURE__ */ new Map();
217301
+ /** Hook admission crosses an async anchor + durable-inbox barrier. Coalesce a
217302
+ * retransmit that arrives before that barrier settles instead of dispatching
217303
+ * the same delivery twice. */
217304
+ pendingRelayMsgAcks = /* @__PURE__ */ new Map();
216877
217305
  agentCallDeliveries = /* @__PURE__ */ new Map();
216878
217306
  orchestrationDeadlines = /* @__PURE__ */ new Map();
216879
217307
  /**
@@ -216895,7 +217323,26 @@ var Daemon = class Daemon {
216895
217323
  this.log.debug(`relay: duplicate rd/msg ${dedupKey} — replaying ack (no re-dispatch)`);
216896
217324
  return prior;
216897
217325
  }
216898
- const ack = msg.source === "webchat" ? this.dispatchRelayOp(msg, chat) : msg.source === "im" ? this.handleRelayIm(msg) : this.dispatchRelayHook(msg);
217326
+ const pending = this.pendingRelayMsgAcks.get(dedupKey);
217327
+ if (pending) return pending;
217328
+ if (msg.source === "hook") {
217329
+ const task = this.dispatchRelayHook(msg).catch((err) => {
217330
+ this.log.error(`hook admission failed for ${dedupKey}: ${formatErr(err)}`);
217331
+ return {
217332
+ msgId: msg.msgId,
217333
+ accepted: false,
217334
+ reason: "durability"
217335
+ };
217336
+ }).then((ack) => {
217337
+ this.pendingRelayMsgAcks.delete(dedupKey);
217338
+ if (this.relayMsgAcks.size >= 2e3) this.relayMsgAcks.clear();
217339
+ this.relayMsgAcks.set(dedupKey, ack);
217340
+ return ack;
217341
+ });
217342
+ this.pendingRelayMsgAcks.set(dedupKey, task);
217343
+ return task;
217344
+ }
217345
+ const ack = msg.source === "webchat" ? this.dispatchRelayOp(msg, chat) : this.handleRelayIm(msg);
216899
217346
  if (this.relayMsgAcks.size >= 2e3) this.relayMsgAcks.clear();
216900
217347
  this.relayMsgAcks.set(dedupKey, ack);
216901
217348
  return ack;
@@ -217397,9 +217844,10 @@ var Daemon = class Daemon {
217397
217844
  * Ack-verdict gate for one hook fire (`rd/msg` hook member) — the mirror of
217398
217845
  * {@link dispatchWebchatTurn}'s synchronous gates: the relay's rc/run-report
217399
217846
  * needs a REASONED rejection now, not a silently dropped fire-and-forget
217400
- * dispatch. Accepted fires run async via {@link onHookFire}.
217847
+ * dispatch. Accepted is returned only after {@link onHookFire} has crossed
217848
+ * the durable-inbox admission barrier; the model turn itself remains async.
217401
217849
  */
217402
- dispatchRelayHook(msg) {
217850
+ async dispatchRelayHook(msg) {
217403
217851
  if (!this.agents.has(msg.agentId)) {
217404
217852
  this.log.warn(`hook: no agent "${msg.agentId}" on this daemon — rejecting fire ${msg.msgId}`);
217405
217853
  return {
@@ -217432,10 +217880,11 @@ var Daemon = class Daemon {
217432
217880
  reason: "draining"
217433
217881
  };
217434
217882
  }
217435
- this.onHookFire(msg).catch((err) => this.log.error(`hook fire dispatch failed for agent "${msg.agentId}": ${formatErr(err)}`));
217883
+ const admission = await this.onHookFire(msg);
217436
217884
  return {
217437
217885
  msgId: msg.msgId,
217438
- accepted: true
217886
+ accepted: admission.accepted,
217887
+ ...admission.reason ? { reason: admission.reason } : {}
217439
217888
  };
217440
217889
  }
217441
217890
  /**
@@ -217448,25 +217897,36 @@ var Daemon = class Daemon {
217448
217897
  * without one the fire runs headless.
217449
217898
  */
217450
217899
  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
217900
  const nmsg = buildHookMessage(msg, randomUUID());
217901
+ if (this.store.hasInbox(nmsg.msgId)) {
217902
+ this.log.debug(`hook: durable duplicate ${nmsg.msgId} — replaying accepted admission`);
217903
+ return { accepted: true };
217904
+ }
217905
+ const snapshot = hookSnapshot(msg);
217906
+ const hookContext = {
217907
+ hookId: msg.hookId,
217908
+ agentId: msg.agentId,
217909
+ deliveryKey: msg.deliveryKey,
217910
+ firedAt: msg.firedAt,
217911
+ ...msg.event ? { event: msg.event } : {},
217912
+ ...snapshot ? { snapshot } : {},
217913
+ ...msg.github ? { github: msg.github } : {}
217914
+ };
217463
217915
  const c = msg.context;
217464
217916
  if (c?.source === "github" && c.action === "deleted" && !msg.target) {
217465
217917
  const key = sessionKey(nmsg.platform, nmsg.channel, nmsg.thread ?? nmsg.msgId, msg.agentId);
217466
217918
  if (!this.store.getSession(key)?.acpSessionId) {
217467
217919
  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;
217920
+ const entry = {
217921
+ agentId: msg.agentId,
217922
+ msg: nmsg,
217923
+ initAbort: new AbortController(),
217924
+ hookContext,
217925
+ resolve: () => {},
217926
+ reject: () => {}
217927
+ };
217928
+ if (this.persistInbox(entry, key, { required: true }) !== "existing") this.emitHookCompletion(hookContext, "success", { reason: "deleted: no existing session" }, entry);
217929
+ return { accepted: true };
217470
217930
  }
217471
217931
  }
217472
217932
  const githubReply = c?.source === "github" && c.repo && c.number !== void 0 ? {
@@ -217474,14 +217934,223 @@ var Daemon = class Daemon {
217474
217934
  repo: c.repo,
217475
217935
  number: c.number
217476
217936
  } : void 0;
217937
+ if (githubReply) hookContext.githubReply = githubReply;
217938
+ const anchored = await this.anchorTrigger(msg.agentId, nmsg, msg.target, hookAnchorText(msg), `hook "${msg.hookId}"`);
217939
+ if (!anchored) return {
217940
+ accepted: false,
217941
+ reason: "dropped"
217942
+ };
217943
+ let admission;
217944
+ this.dispatch(msg.agentId, anchored, msg.target?.integrationId, void 0, void 0, {
217945
+ requireDurable: true,
217946
+ onAdmission: (result) => {
217947
+ admission = result;
217948
+ }
217949
+ }, githubReply, hookContext).catch((err) => this.log.error(`hook turn failed for agent "${msg.agentId}": ${formatErr(err)}`));
217950
+ if (!admission) throw new Error("hook admission barrier did not settle synchronously");
217951
+ if (!admission.accepted) return {
217952
+ accepted: false,
217953
+ reason: admission.reason ?? "durability"
217954
+ };
217955
+ return { accepted: true };
217956
+ }
217957
+ /** Resolve a PR revision if the webhook omitted it (notably
217958
+ * issue_comment), cross the CP hook/start barrier, then return the active
217959
+ * review authority. Failure only disables structured effects; the ordinary
217960
+ * agent turn and final poster continue. */
217961
+ async prepareGithubTurn(entry) {
217962
+ const hook = entry.hookContext;
217963
+ const snapshot = hook?.snapshot;
217964
+ const github = hook?.github;
217965
+ 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;
217966
+ if (this.cfg.daemonId && snapshot.dispatchDaemonId !== this.cfg.daemonId) {
217967
+ this.log.warn(`github review: stale dispatch daemon for ${hook.hookId}:${hook.deliveryKey}`);
217968
+ return;
217969
+ }
217970
+ if (!github.headSha || !github.baseSha) try {
217971
+ const postToken = await this.gitCreds.getPostToken(hook.agentId, github.repoFullName, hook.hookId);
217972
+ const revision = await this.githubReviewClient.getPull(postToken.token, github.repoFullName, github.pullNumber);
217973
+ hook.github = {
217974
+ ...github,
217975
+ headSha: revision.headSha,
217976
+ baseSha: revision.baseSha,
217977
+ reportSha: revision.headSha,
217978
+ isDraft: revision.draft
217979
+ };
217980
+ this.persistHookState(entry, void 0, true);
217981
+ } catch (err) {
217982
+ this.log.warn(`github review: unable to resolve PR revision (${formatErr(err)})`);
217983
+ return;
217984
+ }
217985
+ const trusted = hook.github;
217986
+ if (!trusted.headSha || !trusted.baseSha || trusted.pullNumber === void 0) return void 0;
217987
+ const client = this.cpClient;
217988
+ if (!client) return void 0;
217989
+ const payload = {
217990
+ hookId: hook.hookId,
217991
+ agentId: hook.agentId,
217992
+ deliveryKey: hook.deliveryKey,
217993
+ ...hook.event ? { event: hook.event } : {},
217994
+ github: {
217995
+ ...trusted,
217996
+ reportSha: trusted.reportSha ?? trusted.headSha
217997
+ },
217998
+ ...snapshot
217999
+ };
218000
+ let started = false;
218001
+ for (let attempt = 0; attempt < 3 && !started; attempt += 1) try {
218002
+ await client.startHook(payload);
218003
+ started = true;
218004
+ } catch (err) {
218005
+ if (attempt === 2) {
218006
+ this.log.warn(`github review: hook/start rejected (${formatErr(err)})`);
218007
+ return;
218008
+ }
218009
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
218010
+ }
218011
+ if (snapshot.reviewPolicy === "off") return void 0;
218012
+ const recoverableAttempt = hook.reviewAttemptId !== void 0 && hook.reviewRequestedEvent !== void 0 && hook.reviewRequestedVerdict !== void 0 && (hook.reviewResult === void 0 || hook.reviewResult.state === "ambiguous");
218013
+ const active = {
218014
+ entry,
218015
+ hook,
218016
+ snapshot,
218017
+ repoId: trusted.repoId,
218018
+ repoFullName: trusted.repoFullName,
218019
+ pullNumber: trusted.pullNumber,
218020
+ expectedHeadSha: trusted.headSha,
218021
+ expectedBaseSha: trusted.baseSha,
218022
+ reportSha: trusted.reportSha ?? trusted.headSha,
218023
+ reviewState: hook.reviewAttemptId === void 0 || recoverableAttempt ? "idle" : "done"
218024
+ };
218025
+ if (recoverableAttempt) await this.reconcileGithubReviewAttempt(active);
218026
+ return active;
218027
+ }
218028
+ /** Store the immediate and terminal-report copies together so they cannot
218029
+ * drift across submit and restart-recovery paths. */
218030
+ persistGithubReviewEffect(active, attemptId, effect, required = false) {
218031
+ const result = reviewResultForWire(effect);
218032
+ active.hook.reviewResult = result;
218033
+ active.hook.reviewReportAttemptId = attemptId;
218034
+ active.hook.reviewReportResult = result;
218035
+ this.persistHookState(active.entry, void 0, required);
218036
+ return result;
218037
+ }
218038
+ /** A daemon restart may replay an attempt after an ambiguous POST/list race.
218039
+ * Before prompting the model, perform a GET-only marker reconciliation. A
218040
+ * still-missing marker remains blocked; only an explicit tool invocation can
218041
+ * cross the full reauthorization + revision fence and retry the mutation. */
218042
+ async reconcileGithubReviewAttempt(active) {
218043
+ const cp = this.cpClient;
218044
+ const attemptId = active.hook.reviewAttemptId;
218045
+ const requestedEvent = active.hook.reviewRequestedEvent;
218046
+ const requestedVerdict = active.hook.reviewRequestedVerdict;
218047
+ if (!cp || !attemptId || !requestedEvent || !requestedVerdict) return;
217477
218048
  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" });
218049
+ const authorized = await cp.authorizeGithubReview({
218050
+ hookId: active.hook.hookId,
218051
+ deliveryKey: active.hook.deliveryKey,
218052
+ attemptId,
218053
+ requestedEvent,
218054
+ requestedVerdict,
218055
+ snapshot: active.snapshot
218056
+ });
218057
+ if (!authorizedReviewTargetMatches(active, attemptId, authorized)) {
218058
+ this.log.warn("github review: recovery authorization returned a mismatched target");
218059
+ return;
218060
+ }
218061
+ const effect = await this.githubReviewClient.reconcile(authorizedReviewTarget(active, attemptId, authorized, true), requestedEvent, requestedVerdict);
218062
+ if (effect.state !== "submitted") return;
218063
+ const result = this.persistGithubReviewEffect(active, attemptId, effect, true);
218064
+ await cp.reportGithubReviewResult({
218065
+ hookId: active.hook.hookId,
218066
+ deliveryKey: active.hook.deliveryKey,
218067
+ attemptId,
218068
+ snapshot: active.snapshot,
218069
+ result
218070
+ });
218071
+ active.reviewState = "done";
218072
+ } catch (err) {
218073
+ this.log.warn(`github review: replay reconciliation deferred (${formatErr(err)})`);
218074
+ }
218075
+ }
218076
+ async submitGithubReview(req) {
218077
+ const key = sessionKey(req.platform, req.channel, req.thread, req.agentId);
218078
+ const active = this.activeGithubTurnMeta.get(key);
218079
+ if (!active || active.hook.agentId !== req.agentId) throw new Error("formal GitHub review is only available during the active PR hook turn");
218080
+ if (!reviewPolicyAllows(active.snapshot.reviewPolicy, req.event)) throw new Error(`${req.event} exceeds this hook's ${active.snapshot.reviewPolicy} review policy`);
218081
+ if (active.reviewState !== "idle") throw new Error("this PR hook turn already has a formal review attempt");
218082
+ active.reviewState = "submitting";
218083
+ const recovering = active.hook.reviewAttemptId !== void 0;
218084
+ if (recovering) {
218085
+ if (active.hook.reviewRequestedEvent === void 0 || active.hook.reviewRequestedVerdict === void 0 || active.hook.reviewRequestedEvent !== req.event || active.hook.reviewRequestedVerdict !== req.verdict) {
218086
+ active.reviewState = "idle";
218087
+ throw new Error("a recovered formal-review attempt must keep its original event and verdict");
218088
+ }
218089
+ } else {
218090
+ active.hook.reviewRequestedEvent = req.event;
218091
+ active.hook.reviewRequestedVerdict = req.verdict;
218092
+ }
218093
+ const attemptId = active.hook.reviewAttemptId ?? randomUUID();
218094
+ active.hook.reviewAttemptId = attemptId;
218095
+ const entry = active.entry;
218096
+ try {
218097
+ this.persistHookState(entry, void 0, true);
218098
+ } catch (err) {
218099
+ if (!recovering) {
218100
+ delete active.hook.reviewAttemptId;
218101
+ delete active.hook.reviewRequestedEvent;
218102
+ delete active.hook.reviewRequestedVerdict;
218103
+ }
218104
+ active.reviewState = "idle";
218105
+ throw new Error(`formal review durability barrier failed: ${formatErr(err)}`);
218106
+ }
218107
+ const cp = this.cpClient;
218108
+ if (!cp) {
218109
+ active.reviewState = "done";
218110
+ throw new Error("control plane is not connected; formal review denied");
218111
+ }
218112
+ let authorized;
218113
+ try {
218114
+ authorized = await cp.authorizeGithubReview({
218115
+ hookId: active.hook.hookId,
218116
+ deliveryKey: active.hook.deliveryKey,
218117
+ attemptId,
218118
+ requestedEvent: req.event,
218119
+ requestedVerdict: req.verdict,
218120
+ snapshot: active.snapshot
218121
+ });
217481
218122
  } catch (err) {
217482
- report("failed", { reason: err.message?.slice(0, 300) || "dispatch failed" });
218123
+ active.reviewState = "done";
217483
218124
  throw err;
217484
218125
  }
218126
+ if (!authorizedReviewTargetMatches(active, attemptId, authorized)) {
218127
+ active.reviewState = "done";
218128
+ throw new Error("control plane returned a mismatched formal-review target");
218129
+ }
218130
+ const effect = await this.githubReviewClient.submit(authorizedReviewTarget(active, attemptId, authorized, recovering), req);
218131
+ const result = this.persistGithubReviewEffect(active, attemptId, effect);
218132
+ try {
218133
+ await cp.reportGithubReviewResult({
218134
+ hookId: active.hook.hookId,
218135
+ deliveryKey: active.hook.deliveryKey,
218136
+ attemptId,
218137
+ snapshot: active.snapshot,
218138
+ result
218139
+ });
218140
+ if (effect.state === "not_submitted") {
218141
+ delete active.hook.reviewAttemptId;
218142
+ delete active.hook.reviewRequestedEvent;
218143
+ delete active.hook.reviewRequestedVerdict;
218144
+ delete active.hook.reviewResult;
218145
+ active.reviewState = "idle";
218146
+ this.persistHookState(entry);
218147
+ } else if (effect.state === "ambiguous") active.reviewState = "idle";
218148
+ else active.reviewState = "done";
218149
+ } catch (err) {
218150
+ active.reviewState = effect.state === "ambiguous" ? "idle" : "done";
218151
+ this.log.warn(`github review: immediate result report failed (${formatErr(err)})`);
218152
+ }
218153
+ return effect;
217485
218154
  }
217486
218155
  /** The op-switch behind {@link handleRelayMsg} (dedup handled by the caller). */
217487
218156
  dispatchRelayOp(msg, chat) {
@@ -218075,26 +218744,142 @@ var Daemon = class Daemon {
218075
218744
  * durable row would be un-replayable. Sets `entry.inboxId` so every terminal path can delete
218076
218745
  * the row. The row id is the message's stable deliveryId/msgId (§6.3) — idempotent re-append.
218077
218746
  */
218078
- persistInbox(entry, key) {
218079
- if (entry.webchat) return;
218747
+ persistInbox(entry, key, options = {}) {
218748
+ if (entry.webchat) return "skipped";
218080
218749
  const id = entry.callMeta?.deliveryId ?? entry.msg.msgId;
218081
218750
  try {
218082
- this.store.appendInbox({
218751
+ const inserted = this.store.appendInbox({
218083
218752
  id,
218084
218753
  sessionKey: key,
218085
218754
  agentId: entry.agentId,
218086
218755
  msg: JSON.stringify(entry.msg),
218087
218756
  integrationId: entry.integrationId ?? null,
218088
218757
  callMeta: entry.callMeta ? JSON.stringify(entry.callMeta) : null,
218758
+ hookContext: entry.hookContext ? JSON.stringify(entry.hookContext) : null,
218759
+ posterPublishState: entry.posterPublishState ?? null,
218089
218760
  isQueueCmd: entry.isQueueCmd ? 1 : null,
218090
218761
  loopGuardCounted: 1,
218091
218762
  enqueuedAt: monotonicTs()
218092
218763
  });
218764
+ if (!inserted && !options.adoptExisting) return "existing";
218093
218765
  entry.inboxId = id;
218094
218766
  this.liveInboxIds.add(id);
218767
+ return inserted ? "inserted" : "adopted";
218095
218768
  } catch (err) {
218769
+ if (options.required) throw err;
218096
218770
  this.log.warn(`durable inbox: append failed for ${id}: ${err.message}`);
218771
+ return "failed";
218772
+ }
218773
+ }
218774
+ persistHookState(entry, posterPublishState, required = false) {
218775
+ if (!entry.inboxId || !entry.hookContext) {
218776
+ if (required) throw new Error("hook state has no durable inbox row");
218777
+ return;
218778
+ }
218779
+ if (posterPublishState) entry.posterPublishState = posterPublishState;
218780
+ try {
218781
+ if (!this.store.updateInboxHookState(entry.inboxId, JSON.stringify(entry.hookContext), posterPublishState)) throw new Error("durable inbox row is missing");
218782
+ } catch (err) {
218783
+ if (required) throw err;
218784
+ this.log.warn(`durable inbox: hook state update failed for ${entry.inboxId}: ${err.message}`);
218785
+ }
218786
+ }
218787
+ emitHookCompletion(hook, status, extra = {}, owner) {
218788
+ if (owner?.hookTerminalReceipt) return;
218789
+ const start = Date.parse(hook.turnStartedAt ?? hook.firedAt);
218790
+ const reviewAttemptId = hook.reviewReportAttemptId ?? hook.reviewAttemptId;
218791
+ const reviewResult = hook.reviewReportResult ?? hook.reviewResult;
218792
+ const report = {
218793
+ hookId: hook.hookId,
218794
+ agentId: hook.agentId,
218795
+ deliveryKey: hook.deliveryKey,
218796
+ ...hook.snapshot ?? {},
218797
+ ...hook.event ? { event: hook.event } : {},
218798
+ ...hook.github ? { github: hook.github } : {},
218799
+ status,
218800
+ durationMs: Number.isFinite(start) ? Math.max(0, this.clock.now() - start) : 0,
218801
+ ...extra,
218802
+ ...reviewAttemptId && reviewResult ? {
218803
+ reviewAttemptId,
218804
+ reviewResult
218805
+ } : {}
218806
+ };
218807
+ let reportInboxId;
218808
+ if (owner?.inboxId) try {
218809
+ const completed = this.store.completeHookInbox(owner.inboxId, JSON.stringify(report), this.clock.now());
218810
+ if (completed === "already-terminal") {
218811
+ owner.hookTerminalReceipt = true;
218812
+ this.liveInboxIds.delete(owner.inboxId);
218813
+ return;
218814
+ }
218815
+ if (completed === "missing") {
218816
+ this.log.warn(`durable inbox: hook terminal receipt owner is missing for ${owner.inboxId}`);
218817
+ this.liveInboxIds.delete(owner.inboxId);
218818
+ } else {
218819
+ owner.hookTerminalReceipt = true;
218820
+ this.liveInboxIds.delete(owner.inboxId);
218821
+ reportInboxId = owner.inboxId;
218822
+ }
218823
+ } catch (err) {
218824
+ this.log.warn(`durable inbox: hook terminal receipt failed for ${owner.inboxId}: ${formatErr(err)}`);
218097
218825
  }
218826
+ this.sendHookReport(report, reportInboxId);
218827
+ }
218828
+ /** Send one durable terminal report and release only its outbox payload after
218829
+ * the CP's correlated persistence/projection ACK. The stable id receipt stays
218830
+ * bounded locally to absorb later relay redelivery. */
218831
+ sendHookReport(report, inboxId) {
218832
+ if (!this.cpClient) return;
218833
+ if (inboxId && this.hookReportInflight.has(inboxId)) return;
218834
+ if (inboxId && this.hookReportInflight.size >= MAX_HOOK_REPORT_INFLIGHT) {
218835
+ this.scheduleHookReportRetry();
218836
+ return;
218837
+ }
218838
+ if (inboxId) this.hookReportInflight.add(inboxId);
218839
+ const connectionId = this.hookReportConnectionId;
218840
+ let refillDrainSlot = false;
218841
+ Promise.resolve(this.cpClient.emitHookReport(report)).then((result) => {
218842
+ if (!inboxId) return;
218843
+ if (result === "legacy-sent") {
218844
+ try {
218845
+ if (!this.store.markLegacyHookReportSent(inboxId, connectionId)) throw new Error("durable inbox row is missing");
218846
+ refillDrainSlot = true;
218847
+ } catch (err) {
218848
+ this.log.warn(`durable inbox: legacy report stamp failed for ${inboxId}: ${formatErr(err)}`);
218849
+ this.scheduleHookReportRetry();
218850
+ }
218851
+ return;
218852
+ }
218853
+ refillDrainSlot = true;
218854
+ try {
218855
+ this.store.acknowledgeHookInbox(inboxId);
218856
+ } catch (err) {
218857
+ this.log.warn(`durable inbox: hook report ACK cleanup failed for ${inboxId}: ${formatErr(err)}`);
218858
+ }
218859
+ }).catch((err) => {
218860
+ if (typeof err === "object" && err !== null && "retryable" in err && err.retryable === false && inboxId) {
218861
+ refillDrainSlot = true;
218862
+ try {
218863
+ this.store.acknowledgeHookInbox(inboxId);
218864
+ } catch (cleanupError) {
218865
+ this.log.warn(`durable inbox: hook report dead-letter failed for ${inboxId}: ${formatErr(cleanupError)}`);
218866
+ }
218867
+ this.log.warn(`hook report permanently rejected for ${inboxId}: ${formatErr(err)}`);
218868
+ return;
218869
+ }
218870
+ this.log.debug(`hook report retained for retry: ${formatErr(err)}`);
218871
+ this.scheduleHookReportRetry();
218872
+ }).finally(() => {
218873
+ if (inboxId) this.hookReportInflight.delete(inboxId);
218874
+ if (refillDrainSlot) this.scheduleHookReportRetry(250);
218875
+ });
218876
+ }
218877
+ scheduleHookReportRetry(delayMs = 5e3) {
218878
+ if (this.draining || this.hookReportRetryTimer !== void 0) return;
218879
+ this.hookReportRetryTimer = this.clock.setTimeout(() => {
218880
+ this.hookReportRetryTimer = void 0;
218881
+ this.replayHookTerminalReports();
218882
+ }, delayMs);
218098
218883
  }
218099
218884
  /** Remove an entry's durable inbox row once its turn reaches ANY terminal state (success,
218100
218885
  * reject/fail-stop, cancel, gate-drop) so it is not replayed on the next startup. No-op
@@ -218103,6 +218888,7 @@ var Daemon = class Daemon {
218103
218888
  removeInbox(entry) {
218104
218889
  if (!entry.inboxId) return;
218105
218890
  this.liveInboxIds.delete(entry.inboxId);
218891
+ if (entry.hookContext) return;
218106
218892
  try {
218107
218893
  this.store.removeInbox(entry.inboxId);
218108
218894
  } catch (err) {
@@ -218130,6 +218916,7 @@ var Daemon = class Daemon {
218130
218916
  let removed = 0;
218131
218917
  try {
218132
218918
  for (const row of this.store.listInboxBySessionKeyFifo()) {
218919
+ if (row.hookContext || row.terminalReport) continue;
218133
218920
  let msg;
218134
218921
  try {
218135
218922
  msg = JSON.parse(row.msg);
@@ -218146,8 +218933,8 @@ var Daemon = class Daemon {
218146
218933
  }
218147
218934
  return removed;
218148
218935
  }
218149
- isLoopGuardOpen(msg) {
218150
- return usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopGuardScope(msg));
218936
+ isLoopGuardOpen(msg, includeHook = false) {
218937
+ return (usesLoopGuard(msg) || includeHook) && this.store.isLoopGuardOpen(loopGuardScope(msg));
218151
218938
  }
218152
218939
  /** First-open side effects for a durable conversation circuit: purge restart work,
218153
218940
  * drop every matching serial queue, cancel live ACP turns across ALL agents sharing
@@ -218214,41 +219001,71 @@ var Daemon = class Daemon {
218214
219001
  ...error ? { error } : {}
218215
219002
  });
218216
219003
  }
218217
- dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply) {
219004
+ dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply, hookContext, posterPublishState = githubReply ? "not_started" : void 0) {
218218
219005
  return new Promise((resolve, reject) => {
219006
+ let admissionSettled = false;
219007
+ const settleAdmission = (result) => {
219008
+ if (admissionSettled) return;
219009
+ admissionSettled = true;
219010
+ opts?.onAdmission?.(result);
219011
+ };
218219
219012
  if (this.draining || this.drainingAgents.has(agentId)) {
218220
219013
  this.log.debug(`dispatch: skipped for agent "${agentId}" (draining)`);
219014
+ settleAdmission({
219015
+ accepted: false,
219016
+ reason: "draining"
219017
+ });
218221
219018
  resolve(null);
218222
219019
  return;
218223
219020
  }
218224
219021
  if (this.paused(agentId)) {
218225
219022
  this.log.info(`dispatch: skipped for agent "${agentId}" (paused)`);
219023
+ settleAdmission({
219024
+ accepted: false,
219025
+ reason: "paused"
219026
+ });
218226
219027
  resolve(null);
218227
219028
  return;
218228
219029
  }
218229
219030
  if (this.safetyDrainingAgents.has(agentId)) {
218230
219031
  if (opts?.fromInboxReplay) this.waitForSafetyDrain(agentId).then(() => {
218231
219032
  if (this.draining || !this.agents.has(agentId)) {
219033
+ settleAdmission({
219034
+ accepted: false,
219035
+ reason: "draining"
219036
+ });
218232
219037
  resolve(null);
218233
219038
  return;
218234
219039
  }
218235
219040
  if (opts.inboxReplayId !== void 0 && !this.store.hasInbox(opts.inboxReplayId)) {
219041
+ settleAdmission({
219042
+ accepted: false,
219043
+ reason: "dropped"
219044
+ });
218236
219045
  resolve(null);
218237
219046
  return;
218238
219047
  }
218239
- this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply).then(resolve, reject);
219048
+ this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply, hookContext, posterPublishState).then(resolve, reject);
218240
219049
  }).catch(reject);
218241
219050
  else {
218242
219051
  this.log.info(`dispatch: skipped for agent "${agentId}" (stopping interrupted work)`);
219052
+ settleAdmission({
219053
+ accepted: false,
219054
+ reason: "busy"
219055
+ });
218243
219056
  resolve(null);
218244
219057
  }
218245
219058
  return;
218246
219059
  }
218247
219060
  const key = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, agentId);
218248
219061
  const loopScope = loopGuardScope(msg);
218249
- if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
219062
+ if ((usesLoopGuard(msg) || hookContext !== void 0) && this.store.isLoopGuardOpen(loopScope)) {
218250
219063
  if (opts?.fromInboxReplay) this.purgeLoopScopeInbox(loopScope);
218251
219064
  this.log.warn(`dispatch: skipped ${msg.msgId}; loop guard is open for ${loopScope}`);
219065
+ settleAdmission({
219066
+ accepted: false,
219067
+ reason: "loop_protection"
219068
+ });
218252
219069
  resolve(null);
218253
219070
  return;
218254
219071
  }
@@ -218262,6 +219079,10 @@ var Daemon = class Daemon {
218262
219079
  });
218263
219080
  else this.purgeLoopScopeInbox(loopScope);
218264
219081
  } else this.log.warn(`dispatch: dropped malformed Slack platform event ${msg.msgId}`);
219082
+ settleAdmission({
219083
+ accepted: false,
219084
+ reason: "malformed_platform_event"
219085
+ });
218265
219086
  resolve(null);
218266
219087
  return;
218267
219088
  }
@@ -218272,8 +219093,10 @@ var Daemon = class Daemon {
218272
219093
  ...integrationId !== void 0 ? { integrationId } : {},
218273
219094
  ...webchat ? { webchat } : {},
218274
219095
  ...callMeta ? { callMeta } : {},
219096
+ ...hookContext ? { hookContext } : {},
218275
219097
  ...opts?.isQueueCmd ? { isQueueCmd: true } : {},
218276
219098
  ...githubReply ? { githubReply } : {},
219099
+ ...posterPublishState ? { posterPublishState } : {},
218277
219100
  resolve,
218278
219101
  reject
218279
219102
  };
@@ -218281,25 +219104,81 @@ var Daemon = class Daemon {
218281
219104
  const q = this.serialQueue.get(key) ?? [];
218282
219105
  if (q.length >= MAX_QUEUED_PER_SESSION) {
218283
219106
  this.log.warn(`dispatch: queue full for session ${key} (${q.length}) — rejecting (queue_full)`);
219107
+ settleAdmission({
219108
+ accepted: false,
219109
+ reason: "queue_full"
219110
+ });
218284
219111
  reject(new QueueFullError(key));
218285
219112
  return;
218286
219113
  }
218287
219114
  if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
219115
+ settleAdmission({
219116
+ accepted: false,
219117
+ reason: "loop_protection"
219118
+ });
219119
+ resolve(null);
219120
+ return;
219121
+ }
219122
+ let persistence;
219123
+ try {
219124
+ persistence = this.persistInbox(entry, key, {
219125
+ required: opts?.requireDurable,
219126
+ adoptExisting: opts?.adoptExistingInbox
219127
+ });
219128
+ } catch (err) {
219129
+ settleAdmission({
219130
+ accepted: false,
219131
+ reason: "durability"
219132
+ });
219133
+ reject(err);
219134
+ return;
219135
+ }
219136
+ if (persistence === "existing") {
219137
+ settleAdmission({
219138
+ accepted: true,
219139
+ duplicate: true
219140
+ });
218288
219141
  resolve(null);
218289
219142
  return;
218290
219143
  }
218291
219144
  q.push(entry);
218292
219145
  this.serialQueue.set(key, q);
218293
- this.persistInbox(entry, key);
219146
+ settleAdmission({ accepted: true });
218294
219147
  this.log.debug(`dispatch: queued behind in-flight turn for session ${key} (depth ${q.length})`);
218295
219148
  return;
218296
219149
  }
218297
219150
  if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
219151
+ settleAdmission({
219152
+ accepted: false,
219153
+ reason: "loop_protection"
219154
+ });
219155
+ resolve(null);
219156
+ return;
219157
+ }
219158
+ let persistence;
219159
+ try {
219160
+ persistence = this.persistInbox(entry, key, {
219161
+ required: opts?.requireDurable,
219162
+ adoptExisting: opts?.adoptExistingInbox
219163
+ });
219164
+ } catch (err) {
219165
+ settleAdmission({
219166
+ accepted: false,
219167
+ reason: "durability"
219168
+ });
219169
+ reject(err);
219170
+ return;
219171
+ }
219172
+ if (persistence === "existing") {
219173
+ settleAdmission({
219174
+ accepted: true,
219175
+ duplicate: true
219176
+ });
218298
219177
  resolve(null);
218299
219178
  return;
218300
219179
  }
218301
- this.persistInbox(entry, key);
218302
219180
  this.inflight.add(key);
219181
+ settleAdmission({ accepted: true });
218303
219182
  this.runLoop(key, entry);
218304
219183
  });
218305
219184
  }
@@ -218355,7 +219234,7 @@ var Daemon = class Daemon {
218355
219234
  dispatchGateReason(entry) {
218356
219235
  if (entry.cancelledReason) return entry.cancelledReason;
218357
219236
  if (this.paused(entry.agentId)) return "pause";
218358
- if (this.isLoopGuardOpen(entry.msg)) return "loop protection";
219237
+ if (this.isLoopGuardOpen(entry.msg, entry.hookContext !== void 0)) return "loop protection";
218359
219238
  }
218360
219239
  beginActiveDispatch(agentId, key) {
218361
219240
  let resolveDone;
@@ -218406,13 +219285,19 @@ var Daemon = class Daemon {
218406
219285
  this.activeGateEntries.set(key, entry);
218407
219286
  if (entry !== firstEntry && (this.draining || this.drainingAgents.has(entry.agentId) || this.paused(entry.agentId))) {
218408
219287
  this.terminateQueuedSink(entry);
218409
- if (!this.draining) this.removeInbox(entry);
219288
+ if (!this.draining) {
219289
+ if (entry.hookContext) this.emitHookCompletion(entry.hookContext, "failed", { reason: "dropped" }, entry);
219290
+ this.removeInbox(entry);
219291
+ }
218410
219292
  entry.resolve(null);
218411
219293
  const rest = this.serialQueue.get(key) ?? [];
218412
219294
  this.serialQueue.delete(key);
218413
219295
  for (const e of rest) {
218414
219296
  this.terminateQueuedSink(e);
218415
- if (!this.draining) this.removeInbox(e);
219297
+ if (!this.draining) {
219298
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: "dropped" }, e);
219299
+ this.removeInbox(e);
219300
+ }
218416
219301
  e.resolve(null);
218417
219302
  }
218418
219303
  this.inflight.delete(key);
@@ -218436,7 +219321,10 @@ var Daemon = class Daemon {
218436
219321
  for (const e of rest) {
218437
219322
  const failStop = new FailStopError(key);
218438
219323
  this.terminateQueuedSink(e, failStop.message);
218439
- if (!this.draining) this.removeInbox(e);
219324
+ if (!this.draining) {
219325
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: "fail_stop" }, e);
219326
+ this.removeInbox(e);
219327
+ }
218440
219328
  e.reject(failStop);
218441
219329
  }
218442
219330
  this.inflight.delete(key);
@@ -218452,13 +219340,23 @@ var Daemon = class Daemon {
218452
219340
  }
218453
219341
  }
218454
219342
  async dispatchOne(entry, key) {
218455
- const { agentId, msg, integrationId, webchat, callMeta, githubReply } = entry;
219343
+ const { agentId, msg, integrationId, webchat, callMeta, githubReply, hookContext } = entry;
218456
219344
  const initialGate = this.dispatchGateReason(entry);
218457
219345
  if (initialGate) {
218458
219346
  this.terminateQueuedSink(entry);
219347
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: initialGate }, entry);
218459
219348
  this.log.info(`dispatch: skipped admitted turn ${msg.msgId} (${initialGate})`);
218460
219349
  return null;
218461
219350
  }
219351
+ if (hookContext && !hookContext.turnStartedAt) {
219352
+ hookContext.turnStartedAt = new Date(this.clock.now()).toISOString();
219353
+ try {
219354
+ this.persistHookState(entry, void 0, true);
219355
+ } catch (err) {
219356
+ this.emitHookCompletion(hookContext, "failed", { reason: "durability" }, entry);
219357
+ throw err;
219358
+ }
219359
+ }
218462
219360
  if (callMeta?.correlationId !== void 0) this.recordWorkerReport(key, callMeta, msg.text);
218463
219361
  const agent = this.agents.get(agentId);
218464
219362
  const agentName = agent.displayName?.trim() || agent.name;
@@ -218510,7 +219408,11 @@ var Daemon = class Daemon {
218510
219408
  } finally {
218511
219409
  releaseReplyConn();
218512
219410
  }
218513
- if (interrupted) return null;
219411
+ if (interrupted) {
219412
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: interrupted }, entry);
219413
+ return null;
219414
+ }
219415
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: "session_start_failed" }, entry);
218514
219416
  throw err;
218515
219417
  }
218516
219418
  const { sessionId, blocks, created } = handled;
@@ -218530,6 +219432,7 @@ var Daemon = class Daemon {
218530
219432
  this.showActivity(replyConn, msg.channel, statusThread, "");
218531
219433
  this.terminateQueuedSink(entry);
218532
219434
  releaseReplyConn();
219435
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", { reason: initializedGate }, entry);
218533
219436
  this.log.info(`dispatch: skipped initialized turn ${msg.msgId} (${initializedGate})`);
218534
219437
  return null;
218535
219438
  }
@@ -218570,13 +219473,17 @@ var Daemon = class Daemon {
218570
219473
  resolveDone,
218571
219474
  ...callMeta ? { callMeta } : {},
218572
219475
  ...pendingWebchat ? { webchat: pendingWebchat } : {},
218573
- ...githubReply ? { github: {
219476
+ ...githubReply && entry.posterPublishState !== "in_flight" && entry.posterPublishState !== "settled" ? { github: {
218574
219477
  ...this.makeGithubReply(agentId, githubReply, sessionId),
218575
219478
  deferredFinalTranscript: false
218576
219479
  } } : {}
218577
219480
  };
218578
219481
  this.pending.set(pendingTurnKey(agentId, sessionId), p);
218579
219482
  if (callMeta) this.activeTurnCallMeta.set(key, callMeta);
219483
+ const activeGithub = await this.prepareGithubTurn(entry).catch((err) => {
219484
+ this.log.warn(`github review: turn setup failed (${formatErr(err)})`);
219485
+ });
219486
+ if (activeGithub) this.activeGithubTurnMeta.set(key, activeGithub);
218580
219487
  let finalPhase = "end";
218581
219488
  try {
218582
219489
  const host = await this.ensureHostAsync(agentId);
@@ -218593,6 +219500,10 @@ var Daemon = class Daemon {
218593
219500
  this.showActivity(replyConn, msg.channel, statusThread, "");
218594
219501
  this.terminateQueuedSink(entry);
218595
219502
  if (readyGate === "loop protection") finalPhase = "problem";
219503
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219504
+ sessionId,
219505
+ reason: readyGate
219506
+ }, entry);
218596
219507
  this.log.info(`dispatch: skipped ready turn ${msg.msgId} (${readyGate})`);
218597
219508
  return null;
218598
219509
  }
@@ -218604,6 +219515,10 @@ var Daemon = class Daemon {
218604
219515
  const { stopReason, usage } = await host.prompt(sessionId, blocks);
218605
219516
  if (p.outputSuppressed) {
218606
219517
  if (p.outputSuppressed === "loop protection") finalPhase = "problem";
219518
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219519
+ sessionId,
219520
+ reason: p.outputSuppressed
219521
+ }, entry);
218607
219522
  return null;
218608
219523
  }
218609
219524
  if (usage) {
@@ -218658,6 +219573,10 @@ var Daemon = class Daemon {
218658
219573
  this.clearIdle(p);
218659
219574
  if (p.outputSuppressed) {
218660
219575
  if (p.outputSuppressed === "loop protection") finalPhase = "problem";
219576
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219577
+ sessionId,
219578
+ reason: p.outputSuppressed
219579
+ }, entry);
218661
219580
  return null;
218662
219581
  }
218663
219582
  finalPhase = "problem";
@@ -218695,6 +219614,10 @@ var Daemon = class Daemon {
218695
219614
  this.showActivity(replyConn, msg.channel, statusThread, "");
218696
219615
  await p.applyChain;
218697
219616
  }
219617
+ if (hookContext && !this.draining) this.emitHookCompletion(hookContext, "failed", {
219618
+ sessionId,
219619
+ reason: "turn_failed"
219620
+ }, entry);
218698
219621
  throw err;
218699
219622
  } finally {
218700
219623
  this.clearCancelBackstop(agentId, sessionId);
@@ -218704,6 +219627,7 @@ var Daemon = class Daemon {
218704
219627
  this.releasePermissions(agentId, sessionId);
218705
219628
  this.releaseElicits(agentId, sessionId);
218706
219629
  if (callMeta) this.activeTurnCallMeta.delete(key);
219630
+ if (activeGithub && this.activeGithubTurnMeta.get(key) === activeGithub) this.activeGithubTurnMeta.delete(key);
218707
219631
  const githubFinal = p.github && !p.outputSuppressed && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
218708
219632
  if (p.github?.deferredFinalTranscript && githubFinal?.trim()) this.store.appendTranscript({
218709
219633
  channel: p.channel,
@@ -218713,7 +219637,19 @@ var Daemon = class Daemon {
218713
219637
  kind: "text",
218714
219638
  text: githubFinal
218715
219639
  });
218716
- if (p.github && !p.outputSuppressed) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
219640
+ if (p.github && !p.outputSuppressed) {
219641
+ let recordedInFlight = false;
219642
+ try {
219643
+ this.persistHookState(entry, "in_flight", true);
219644
+ recordedInFlight = true;
219645
+ } catch (err) {
219646
+ this.log.warn(`github poster: durability barrier failed; final publish skipped (${formatErr(err)})`);
219647
+ }
219648
+ if (recordedInFlight) {
219649
+ await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
219650
+ this.persistHookState(entry, "settled");
219651
+ }
219652
+ }
218717
219653
  this.pending.delete(pendingTurnKey(agentId, sessionId));
218718
219654
  this.store.setSessionState(key, "idle", this.clock.now());
218719
219655
  this.emitSessionMetadataSnapshot({
@@ -218726,6 +219662,7 @@ var Daemon = class Daemon {
218726
219662
  });
218727
219663
  p.resolveDone();
218728
219664
  }
219665
+ if (hookContext) this.emitHookCompletion(hookContext, "success", { sessionId }, entry);
218729
219666
  return sessionId;
218730
219667
  }
218731
219668
  /** Interrupt a session by its LOGICAL sessionKey (shared by pause, `!stop`, `!cancel`,
@@ -218747,6 +219684,7 @@ var Daemon = class Daemon {
218747
219684
  this.serialQueue.delete(key);
218748
219685
  for (const e of queued) {
218749
219686
  this.terminateQueuedSink(e, opts.dropQueued ? void 0 : reason);
219687
+ if (e.hookContext) this.emitHookCompletion(e.hookContext, "failed", { reason: opts.dropQueued ? "dropped" : "cancelled" }, e);
218750
219688
  this.removeInbox(e);
218751
219689
  if (opts.dropQueued) e.resolve(null);
218752
219690
  else e.reject(new FailStopError(key));
@@ -220139,49 +221077,92 @@ var Daemon = class Daemon {
220139
221077
  const purgedPausedAgents = /* @__PURE__ */ new Set();
220140
221078
  const purgedLoopScopes = /* @__PURE__ */ new Set();
220141
221079
  for (const row of rows) {
221080
+ if (row.completedAt !== null && row.completedAt !== void 0) continue;
220142
221081
  if (this.liveInboxIds.has(row.id)) continue;
220143
221082
  if (!this.agents.has(row.agentId)) {
220144
221083
  this.log.warn(`durable inbox: skipping replay of ${row.id} — unknown agent "${row.agentId}"`);
220145
221084
  continue;
220146
221085
  }
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
221086
  let msg;
220155
221087
  let callMeta;
221088
+ let hookContext;
220156
221089
  try {
220157
221090
  msg = JSON.parse(row.msg);
220158
221091
  callMeta = row.callMeta ? JSON.parse(row.callMeta) : void 0;
221092
+ hookContext = row.hookContext ? JSON.parse(row.hookContext) : void 0;
220159
221093
  } catch (err) {
220160
221094
  this.log.warn(`durable inbox: skipping corrupt row ${row.id}: ${err.message}`);
220161
221095
  continue;
220162
221096
  }
221097
+ if (msg.source === "hook" && !hookContext) {
221098
+ this.log.warn(`durable inbox: tombstoning legacy hook row ${row.id} without trusted context`);
221099
+ this.store.removeInbox(row.id);
221100
+ continue;
221101
+ }
221102
+ if (this.paused(row.agentId)) {
221103
+ if (!purgedPausedAgents.has(row.agentId)) {
221104
+ purgedPausedAgents.add(row.agentId);
221105
+ this.purgeAgentInbox(row.agentId);
221106
+ }
221107
+ if (hookContext) this.emitHookCompletion(hookContext, "failed", { reason: "pause" }, { inboxId: row.id });
221108
+ continue;
221109
+ }
220163
221110
  if (isMalformedPlatformTurn(msg) && !msg.isDm) {
220164
221111
  this.store.removeInbox(row.id);
220165
221112
  continue;
220166
221113
  }
220167
221114
  const loopScope = loopGuardScope(msg);
220168
- if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
221115
+ if ((usesLoopGuard(msg) || hookContext !== void 0) && this.store.isLoopGuardOpen(loopScope)) {
220169
221116
  if (!purgedLoopScopes.has(loopScope)) {
220170
221117
  purgedLoopScopes.add(loopScope);
220171
221118
  this.purgeLoopScopeInbox(loopScope);
220172
221119
  }
221120
+ if (hookContext) this.emitHookCompletion(hookContext, "failed", { reason: "loop protection" }, { inboxId: row.id });
220173
221121
  continue;
220174
221122
  }
221123
+ const posterPublishState = row.posterPublishState === "in_flight" || row.posterPublishState === "settled" ? row.posterPublishState : hookContext?.githubReply ? "not_started" : void 0;
220175
221124
  this.dispatch(row.agentId, msg, row.integrationId ?? void 0, void 0, callMeta, {
220176
221125
  ...row.isQueueCmd ? { isQueueCmd: true } : {},
220177
221126
  fromInboxReplay: true,
220178
221127
  inboxReplayId: row.id,
220179
- replay: row.loopGuardCounted === 1
220180
- }).catch(() => {});
221128
+ replay: row.loopGuardCounted === 1,
221129
+ adoptExistingInbox: true,
221130
+ ...hookContext ? { requireDurable: true } : {}
221131
+ }, hookContext?.githubReply, hookContext, posterPublishState).catch(() => {});
220181
221132
  replayed++;
220182
221133
  }
220183
221134
  if (replayed) this.log.info(`durable inbox: replayed ${replayed} admitted message(s) through the serial gate`);
220184
221135
  }
221136
+ /** Re-assert retained metadata-only hook completions whenever the CP socket
221137
+ * becomes ready. HookRepo applies them idempotently; keeping the receipt also
221138
+ * closes the completed-before-restart relay redelivery window. */
221139
+ replayHookTerminalReports() {
221140
+ let rows;
221141
+ try {
221142
+ rows = this.store.listInboxBySessionKeyFifo();
221143
+ } catch (err) {
221144
+ this.log.warn(`durable inbox: terminal report read failed: ${formatErr(err)}`);
221145
+ this.scheduleHookReportRetry();
221146
+ return;
221147
+ }
221148
+ const pending = rows.filter((row) => row.terminalReport && row.legacyReportConnection !== this.hookReportConnectionId && !this.hookReportInflight.has(row.id));
221149
+ const available = Math.max(0, MAX_HOOK_REPORT_INFLIGHT - this.hookReportInflight.size);
221150
+ const batch = pending.slice(0, available);
221151
+ let emitted = 0;
221152
+ for (const row of batch) try {
221153
+ const decoded = HookReport.parse(JSON.parse(row.terminalReport));
221154
+ this.sendHookReport(decoded, row.id);
221155
+ emitted += 1;
221156
+ } catch (err) {
221157
+ this.log.warn(`durable inbox: corrupt terminal hook receipt ${row.id}: ${formatErr(err)}`);
221158
+ try {
221159
+ this.store.acknowledgeHookInbox(row.id);
221160
+ } catch (cleanupError) {
221161
+ this.log.warn(`durable inbox: corrupt receipt cleanup failed for ${row.id}: ${formatErr(cleanupError)}`);
221162
+ }
221163
+ }
221164
+ if (emitted) this.log.info(`durable inbox: re-emitted ${emitted} terminal hook report(s)`);
221165
+ }
220185
221166
  /** daemon/restart + daemon/upgrade (§8.3): ack now, then drain + stop + exit so
220186
221167
  * the supervisor relaunches (the new binary, for upgrade). */
220187
221168
  scheduleFleetExit(kind, targetVersion) {
@@ -220438,7 +221419,7 @@ var Daemon = class Daemon {
220438
221419
  * still runs; a `headless` msg additionally suppresses all platform output
220439
221420
  * in dispatch. Resolves with dispatch's sessionId (null = gate-dropped).
220440
221421
  */
220441
- async fireTrigger(agentId, msg, target, anchorText, label, githubReply) {
221422
+ async anchorTrigger(agentId, msg, target, anchorText, label) {
220442
221423
  if (this.draining || this.drainingAgents.has(agentId) || this.paused(agentId) || this.safetyDrainingAgents.has(agentId)) {
220443
221424
  this.log.info(`${label}: skipped for agent "${agentId}" (paused or draining)`);
220444
221425
  return null;
@@ -220456,7 +221437,12 @@ var Daemon = class Daemon {
220456
221437
  this.log.warn(`${label}: failed to post trigger to ${target.channel} (${formatErr(err)}) — running without anchor`);
220457
221438
  }
220458
221439
  }
220459
- return this.dispatch(agentId, msg, target?.integrationId, void 0, void 0, void 0, githubReply);
221440
+ return msg;
221441
+ }
221442
+ async fireTrigger(agentId, msg, target, anchorText, label, githubReply, hookContext) {
221443
+ const anchored = await this.anchorTrigger(agentId, msg, target, anchorText, label);
221444
+ if (!anchored) return null;
221445
+ return this.dispatch(agentId, anchored, target?.integrationId, void 0, void 0, void 0, githubReply, hookContext);
220460
221446
  }
220461
221447
  /** Build the per-turn GitHub final-answer selector and poster, tokened
220462
221448
  * via the repo-targeted gitcred mint (issues/PR write, no contents — never
@@ -220590,6 +221576,8 @@ var Daemon = class Daemon {
220590
221576
  mcpServerFacts: () => this.mcpServerFacts,
220591
221577
  onReady: () => {
220592
221578
  this.probeRuntimesAndEmit();
221579
+ this.hookReportConnectionId = randomUUID();
221580
+ this.replayHookTerminalReports();
220593
221581
  for (const [integrationId, channels] of this.channelSnapshots) this.cpClient?.emitIntegrationChannels({
220594
221582
  integrationId,
220595
221583
  channels
@@ -220711,6 +221699,10 @@ var Daemon = class Daemon {
220711
221699
  this.coldCancelTimers.clear();
220712
221700
  for (const t of this.slackRetryTimers.values()) this.clock.clearTimeout(t);
220713
221701
  this.slackRetryTimers.clear();
221702
+ if (this.hookReportRetryTimer !== void 0) {
221703
+ this.clock.clearTimeout(this.hookReportRetryTimer);
221704
+ this.hookReportRetryTimer = void 0;
221705
+ }
220714
221706
  for (const t of this.orchestrationDeadlines.values()) this.clock.clearTimeout(t);
220715
221707
  this.orchestrationDeadlines.clear();
220716
221708
  this.metrics?.stop();
@@ -220772,4 +221764,4 @@ async function runForeground(opts, deps = {}) {
220772
221764
  //#endregion
220773
221765
  export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
220774
221766
 
220775
- //# sourceMappingURL=run-foreground-B5Kfk1KO.js.map
221767
+ //# sourceMappingURL=run-foreground-sX3AL91D.js.map