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

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