@vibedeckx/darwin-arm64 0.2.14 → 0.2.16

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.
Files changed (2) hide show
  1. package/dist/bin.js +1360 -187
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -13356,12 +13356,12 @@ var require_semver = __commonJS({
13356
13356
  }
13357
13357
  function replaceHyphen(comp, options) {
13358
13358
  const r = HYPHENRANGE_REGEXP;
13359
- return comp.replace(r, (_3, from, fM, fm, fp32, fpr, fb, to, tM, tm, tp, tpr) => {
13359
+ return comp.replace(r, (_3, from, fM, fm, fp34, fpr, fb, to, tM, tm, tp, tpr) => {
13360
13360
  if (isX(fM)) {
13361
13361
  from = "";
13362
13362
  } else if (isX(fm)) {
13363
13363
  from = `>=${fM}.0.0${options?.includePrerelease ? "-0" : ""}`;
13364
- } else if (isX(fp32)) {
13364
+ } else if (isX(fp34)) {
13365
13365
  from = `>=${fM}.${fm}.0${options?.includePrerelease ? "-0" : ""}`;
13366
13366
  } else if (fpr) {
13367
13367
  from = `>=${from}`;
@@ -87525,12 +87525,12 @@ var require_range = __commonJS({
87525
87525
  debug("replaceGTE0", comp, options);
87526
87526
  return comp.trim().replace(re2[options.includePrerelease ? t.GTE0PRE : t.GTE0], "");
87527
87527
  };
87528
- var hyphenReplace = (incPr) => ($0, from, fM, fm, fp32, fpr, fb, to, tM, tm, tp, tpr) => {
87528
+ var hyphenReplace = (incPr) => ($0, from, fM, fm, fp34, fpr, fb, to, tM, tm, tp, tpr) => {
87529
87529
  if (isX(fM)) {
87530
87530
  from = "";
87531
87531
  } else if (isX(fm)) {
87532
87532
  from = `>=${fM}.0.0${incPr ? "-0" : ""}`;
87533
- } else if (isX(fp32)) {
87533
+ } else if (isX(fp34)) {
87534
87534
  from = `>=${fM}.${fm}.0${incPr ? "-0" : ""}`;
87535
87535
  } else if (fpr) {
87536
87536
  from = `>=${from}`;
@@ -90111,8 +90111,8 @@ var require_pretty_print = __commonJS({
90111
90111
  serializedRoute += serializeMetaData(route.metaData);
90112
90112
  return serializedRoute;
90113
90113
  }
90114
- function mergeSimilarRoutes(routes31) {
90115
- return routes31.reduce((mergedRoutes, route) => {
90114
+ function mergeSimilarRoutes(routes33) {
90115
+ return routes33.reduce((mergedRoutes, route) => {
90116
90116
  for (const nodeRoute of mergedRoutes) {
90117
90117
  if (deepEqual(route.opts.constraints, nodeRoute.opts.constraints) && deepEqual(route.metaData, nodeRoute.metaData)) {
90118
90118
  nodeRoute.method += ", " + route.method;
@@ -90124,18 +90124,18 @@ var require_pretty_print = __commonJS({
90124
90124
  }, []);
90125
90125
  }
90126
90126
  function serializeNode(node, prefix, options) {
90127
- let routes31 = node.routes;
90127
+ let routes33 = node.routes;
90128
90128
  if (options.method === void 0) {
90129
- routes31 = routes31.map(normalizeRoute);
90129
+ routes33 = routes33.map(normalizeRoute);
90130
90130
  }
90131
- routes31 = routes31.map((route) => {
90131
+ routes33 = routes33.map((route) => {
90132
90132
  route.metaData = getRouteMetaData(route, options);
90133
90133
  return route;
90134
90134
  });
90135
90135
  if (options.method === void 0) {
90136
- routes31 = mergeSimilarRoutes(routes31);
90136
+ routes33 = mergeSimilarRoutes(routes33);
90137
90137
  }
90138
- return routes31.map(serializeRoute).join(`
90138
+ return routes33.map(serializeRoute).join(`
90139
90139
  ${prefix}`);
90140
90140
  }
90141
90141
  function buildObjectTree(node, tree, prefix, options) {
@@ -91402,9 +91402,9 @@ var require_find_my_way = __commonJS({
91402
91402
  pathIndex = paramEndIndex;
91403
91403
  }
91404
91404
  };
91405
- Router.prototype._rebuild = function(routes31) {
91405
+ Router.prototype._rebuild = function(routes33) {
91406
91406
  this.reset();
91407
- for (const route of routes31) {
91407
+ for (const route of routes33) {
91408
91408
  const { method, path: path22, opts, handler, store } = route;
91409
91409
  this._on(method, path22, opts, handler, store);
91410
91410
  }
@@ -100612,8 +100612,8 @@ var require_commonjs6 = __commonJS({
100612
100612
  return this.#fullpath = this.name;
100613
100613
  }
100614
100614
  const pv = p2.fullpath();
100615
- const fp32 = pv + (!p2.parent ? "" : this.sep) + name25;
100616
- return this.#fullpath = fp32;
100615
+ const fp34 = pv + (!p2.parent ? "" : this.sep) + name25;
100616
+ return this.#fullpath = fp34;
100617
100617
  }
100618
100618
  /**
100619
100619
  * On platforms other than windows, this is identical to fullpath.
@@ -105329,7 +105329,7 @@ var require_static = __commonJS({
105329
105329
  var { fileURLToPath: fileURLToPath4 } = __require("node:url");
105330
105330
  var { statSync: statSync2 } = __require("node:fs");
105331
105331
  var { glob } = require_commonjs7();
105332
- var fp32 = require_plugin2();
105332
+ var fp34 = require_plugin2();
105333
105333
  var send = require_send2();
105334
105334
  var encodingNegotiator = require_accept_negotiator();
105335
105335
  var contentDisposition = require_content_disposition();
@@ -105433,7 +105433,7 @@ var require_static = __commonJS({
105433
105433
  } else {
105434
105434
  const indexes = new Set(opts.index === void 0 ? ["index.html"] : [].concat(opts.index));
105435
105435
  const indexDirs = /* @__PURE__ */ new Map();
105436
- const routes31 = /* @__PURE__ */ new Set();
105436
+ const routes33 = /* @__PURE__ */ new Set();
105437
105437
  const roots = Array.isArray(sendOptions.root) ? sendOptions.root : [sendOptions.root];
105438
105438
  for (let rootPath of roots) {
105439
105439
  rootPath = rootPath.split(path22.win32.sep).join(path22.posix.sep);
@@ -105449,10 +105449,10 @@ var require_static = __commonJS({
105449
105449
  for (let file2 of files) {
105450
105450
  file2 = file2.split(path22.win32.sep).join(path22.posix.sep);
105451
105451
  const route = prefix + file2;
105452
- if (routes31.has(route)) {
105452
+ if (routes33.has(route)) {
105453
105453
  continue;
105454
105454
  }
105455
- routes31.add(route);
105455
+ routes33.add(route);
105456
105456
  setUpHeadAndGet(routeOpts, route, `/${file2}`, rootPath);
105457
105457
  const key = path22.posix.basename(route);
105458
105458
  if (indexes.has(key) && !indexDirs.has(key)) {
@@ -105747,7 +105747,7 @@ var require_static = __commonJS({
105747
105747
  throw err;
105748
105748
  }
105749
105749
  }
105750
- module.exports = fp32(fastifyStatic2, {
105750
+ module.exports = fp34(fastifyStatic2, {
105751
105751
  fastify: "5.x",
105752
105752
  name: "@fastify/static"
105753
105753
  });
@@ -112390,7 +112390,7 @@ var require_websocket2 = __commonJS({
112390
112390
  var { ServerResponse } = __require("node:http");
112391
112391
  var { PassThrough } = __require("node:stream");
112392
112392
  var { randomBytes: randomBytes3 } = __require("node:crypto");
112393
- var fp32 = require_plugin2();
112393
+ var fp34 = require_plugin2();
112394
112394
  var WebSocket2 = require_ws();
112395
112395
  var Duplexify = require_duplexify();
112396
112396
  var kWs = /* @__PURE__ */ Symbol("ws-socket");
@@ -112576,7 +112576,7 @@ var require_websocket2 = __commonJS({
112576
112576
  }
112577
112577
  next();
112578
112578
  }
112579
- module.exports = fp32(fastifyWebsocket2, {
112579
+ module.exports = fp34(fastifyWebsocket2, {
112580
112580
  fastify: "5.x",
112581
112581
  name: "@fastify/websocket"
112582
112582
  });
@@ -115020,7 +115020,7 @@ var require_multipart2 = __commonJS({
115020
115020
  "use strict";
115021
115021
  var Busboy = require_main();
115022
115022
  var os2 = __require("node:os");
115023
- var fp32 = require_plugin2();
115023
+ var fp34 = require_plugin2();
115024
115024
  var { createWriteStream: createWriteStream2 } = __require("node:fs");
115025
115025
  var { unlink: unlink2 } = __require("node:fs/promises");
115026
115026
  var path22 = __require("node:path");
@@ -115469,7 +115469,7 @@ var require_multipart2 = __commonJS({
115469
115469
  }
115470
115470
  });
115471
115471
  }
115472
- module.exports = fp32(fastifyMultipart2, {
115472
+ module.exports = fp34(fastifyMultipart2, {
115473
115473
  fastify: "5.x",
115474
115474
  name: "@fastify/multipart"
115475
115475
  });
@@ -169019,11 +169019,11 @@ function buildCommand(builderArgs) {
169019
169019
  }
169020
169020
  };
169021
169021
  }
169022
- function* generateRouteMapHelpLines(routes31, docs, args) {
169022
+ function* generateRouteMapHelpLines(routes33, docs, args) {
169023
169023
  const { brief, fullDescription, hideRoute } = docs;
169024
169024
  const { headers } = args.text;
169025
169025
  yield args.ansiColor ? `\x1B[1m${headers.usage}\x1B[22m` : headers.usage;
169026
- for (const [name25, route] of Object.entries(routes31)) {
169026
+ for (const [name25, route] of Object.entries(routes33)) {
169027
169027
  if (!hideRoute || !hideRoute[name25] || args.includeHidden) {
169028
169028
  const externalRouteName = args.config.caseStyle === "convert-camel-to-kebab" ? convertCamelCaseToKebabCase(name25) : name25;
169029
169029
  yield ` ${route.formatUsageLine({
@@ -169053,7 +169053,7 @@ function* generateRouteMapHelpLines(routes31, docs, args) {
169053
169053
  }
169054
169054
  yield "";
169055
169055
  yield args.ansiColor ? `\x1B[1m${headers.commands}\x1B[22m` : headers.commands;
169056
- const visibleRoutes = Object.entries(routes31).filter(
169056
+ const visibleRoutes = Object.entries(routes33).filter(
169057
169057
  ([name25]) => !hideRoute || !hideRoute[name25] || args.includeHidden
169058
169058
  );
169059
169059
  const rows = visibleRoutes.map(([internalRouteName, route]) => {
@@ -169081,24 +169081,24 @@ function* generateRouteMapHelpLines(routes31, docs, args) {
169081
169081
  }
169082
169082
  }
169083
169083
  function buildRouteMap({
169084
- routes: routes31,
169084
+ routes: routes33,
169085
169085
  defaultCommand: defaultCommandRoute,
169086
169086
  docs,
169087
169087
  aliases
169088
169088
  }) {
169089
- if (Object.entries(routes31).length === 0) {
169089
+ if (Object.entries(routes33).length === 0) {
169090
169090
  throw new InternalError("Route map must contain at least one route");
169091
169091
  }
169092
169092
  const activeAliases = aliases ?? {};
169093
169093
  const aliasesByRoute = /* @__PURE__ */ new Map();
169094
169094
  for (const [alias, routeName] of Object.entries(activeAliases)) {
169095
- if (alias in routes31) {
169095
+ if (alias in routes33) {
169096
169096
  throw new InternalError(`Cannot use "${alias}" as an alias when a route with that name already exists`);
169097
169097
  }
169098
169098
  const routeAliases = aliasesByRoute.get(routeName) ?? [];
169099
169099
  aliasesByRoute.set(routeName, [...routeAliases, alias]);
169100
169100
  }
169101
- const defaultCommand = defaultCommandRoute ? routes31[defaultCommandRoute] : void 0;
169101
+ const defaultCommand = defaultCommandRoute ? routes33[defaultCommandRoute] : void 0;
169102
169102
  if (defaultCommand && defaultCommand.kind === RouteMapSymbol) {
169103
169103
  throw new InternalError(
169104
169104
  `Cannot use "${defaultCommandRoute}" as the default command because it is not a Command`
@@ -169107,7 +169107,7 @@ function buildRouteMap({
169107
169107
  const resolveRouteName = (input) => {
169108
169108
  if (input in activeAliases) {
169109
169109
  return activeAliases[input];
169110
- } else if (input in routes31) {
169110
+ } else if (input in routes33) {
169111
169111
  return input;
169112
169112
  }
169113
169113
  };
@@ -169125,7 +169125,7 @@ function buildRouteMap({
169125
169125
  return `${args.prefix.join(" ")} ${routeNames.join("|")} ...`;
169126
169126
  },
169127
169127
  formatHelp: (config2) => {
169128
- const lines = [...generateRouteMapHelpLines(routes31, docs, config2)];
169128
+ const lines = [...generateRouteMapHelpLines(routes33, docs, config2)];
169129
169129
  const text2 = lines.join("\n");
169130
169130
  return text2 + "\n";
169131
169131
  },
@@ -169168,11 +169168,11 @@ function buildRouteMap({
169168
169168
  },
169169
169169
  getRoutingTargetForInput: (input) => {
169170
169170
  const routeName = input in activeAliases ? activeAliases[input] : input;
169171
- return routes31[routeName];
169171
+ return routes33[routeName];
169172
169172
  },
169173
169173
  getAllEntries() {
169174
169174
  const hiddenRoutes = docs.hideRoute;
169175
- return Object.entries(routes31).map(([originalRouteName, target]) => {
169175
+ return Object.entries(routes33).map(([originalRouteName, target]) => {
169176
169176
  return {
169177
169177
  name: {
169178
169178
  original: originalRouteName,
@@ -185055,6 +185055,14 @@ var createCoreRepos = (kdb, h) => ({
185055
185055
  let query = kdb.deleteFrom("projects").where("id", "=", id);
185056
185056
  if (userId) query = query.where("user_id", "=", userId);
185057
185057
  await query.execute();
185058
+ },
185059
+ // Deliberately unscoped: the notification importer runs without a request
185060
+ // context and must DERIVE ownership from the mapped local project rather
185061
+ // than trust any worker-supplied tenant id. Returns "" for solo-mode rows
185062
+ // (the column's default), which callers map to the "local" sentinel.
185063
+ getOwnerId: async (projectId) => {
185064
+ const row = await kdb.selectFrom("projects").select("user_id").where("id", "=", projectId).executeTakeFirst();
185065
+ return row?.user_id;
185058
185066
  }
185059
185067
  },
185060
185068
  settings: {
@@ -185584,6 +185592,15 @@ var createExecutorRepos = (kdb, h) => ({
185584
185592
  });
185585
185593
 
185586
185594
  // src/storage/repositories/agent-sessions.ts
185595
+ var mapRemoteSessionMapping = (row) => ({
185596
+ local_session_id: row.local_session_id,
185597
+ project_id: row.project_id,
185598
+ remote_server_id: row.remote_server_id,
185599
+ remote_session_id: row.remote_session_id,
185600
+ branch: row.branch,
185601
+ notification_sync_start: row.notification_sync_start,
185602
+ notification_watch_until: row.notification_watch_until
185603
+ });
185587
185604
  var mapAgentSession = (row) => ({
185588
185605
  id: row.id,
185589
185606
  project_id: row.project_id,
@@ -185690,6 +185707,17 @@ var createAgentSessionRepos = (kdb, h) => ({
185690
185707
  upsertEntry: async (sessionId, entryIndex, data) => {
185691
185708
  await kdb.insertInto("agent_session_entries").values({ session_id: sessionId, entry_index: entryIndex, data }).onConflict((oc) => oc.columns(["session_id", "entry_index"]).doUpdateSet({ data })).execute();
185692
185709
  },
185710
+ // One transaction, both writes idempotent — see the contract note in
185711
+ // types.ts for why the milestone must ride with the turn_end rather than
185712
+ // being written next to it.
185713
+ upsertTurnEndWithOutbox: async ({ sessionId, entryIndex, entryData, outbox }) => {
185714
+ await kdb.transaction().execute(async (trx) => {
185715
+ await trx.insertInto("agent_session_entries").values({ session_id: sessionId, entry_index: entryIndex, data: entryData }).onConflict((oc) => oc.columns(["session_id", "entry_index"]).doUpdateSet({ data: entryData })).execute();
185716
+ if (outbox) {
185717
+ await trx.insertInto("notification_outbox").values(outbox).onConflict((oc) => oc.column("id").doNothing()).execute();
185718
+ }
185719
+ });
185720
+ },
185693
185721
  getEntries: async (sessionId) => {
185694
185722
  return kdb.selectFrom("agent_session_entries").select(["entry_index", "data"]).where("session_id", "=", sessionId).orderBy("entry_index", "asc").execute();
185695
185723
  },
@@ -185703,14 +185731,21 @@ var createAgentSessionRepos = (kdb, h) => ({
185703
185731
  remoteSessionMappings: {
185704
185732
  // ON CONFLICT SET deliberately omits title_resolved — a re-upsert (e.g.
185705
185733
  // the remote session getting re-mapped after a reconnect) must not reset
185706
- // the "AI title already generated" flag back to false.
185707
- upsert: async (localSessionId, projectId, remoteServerId, remoteSessionId, branch) => {
185734
+ // the "AI title already generated" flag back to false. It omits
185735
+ // notification_sync_start and notification_watch_until for the same reason:
185736
+ // re-mapping a session (reconnect) or reusing a reviewer for a second
185737
+ // review must not downgrade an established from_start policy, and must not
185738
+ // rewind the watch window mid-flight. The sync cursor lives in its own
185739
+ // table and is likewise untouched — a reused reviewer continues from the
185740
+ // boundary it already imported instead of replaying its whole history.
185741
+ upsert: async (localSessionId, projectId, remoteServerId, remoteSessionId, branch, notificationSyncStart) => {
185708
185742
  await kdb.insertInto("remote_session_mappings").values({
185709
185743
  local_session_id: localSessionId,
185710
185744
  project_id: projectId,
185711
185745
  remote_server_id: remoteServerId,
185712
185746
  remote_session_id: remoteSessionId,
185713
- branch
185747
+ branch,
185748
+ notification_sync_start: notificationSyncStart ?? "from_now"
185714
185749
  }).onConflict((oc) => oc.column("local_session_id").doUpdateSet({
185715
185750
  project_id: projectId,
185716
185751
  remote_server_id: remoteServerId,
@@ -185719,10 +185754,43 @@ var createAgentSessionRepos = (kdb, h) => ({
185719
185754
  })).execute();
185720
185755
  },
185721
185756
  getAll: async () => {
185722
- return kdb.selectFrom("remote_session_mappings").select(["local_session_id", "project_id", "remote_server_id", "remote_session_id", "branch"]).execute();
185757
+ const rows = await kdb.selectFrom("remote_session_mappings").selectAll().execute();
185758
+ return rows.map(mapRemoteSessionMapping);
185759
+ },
185760
+ getByLocal: async (localSessionId) => {
185761
+ const row = await kdb.selectFrom("remote_session_mappings").selectAll().where("local_session_id", "=", localSessionId).executeTakeFirst();
185762
+ return row ? mapRemoteSessionMapping(row) : void 0;
185763
+ },
185764
+ getByRemote: async (remoteServerId, remoteSessionId) => {
185765
+ const row = await kdb.selectFrom("remote_session_mappings").selectAll().where("remote_server_id", "=", remoteServerId).where("remote_session_id", "=", remoteSessionId).executeTakeFirst();
185766
+ return row ? mapRemoteSessionMapping(row) : void 0;
185767
+ },
185768
+ // MAX(existing, incoming): concurrent extenders (a new turn plus live
185769
+ // stream activity) must not shorten a longer window one of them already set.
185770
+ extendNotificationWatch: async (localSessionId, until) => {
185771
+ await kdb.updateTable("remote_session_mappings").set({
185772
+ notification_watch_until: sql`MAX(COALESCE(notification_watch_until, 0), ${until})`
185773
+ }).where("local_session_id", "=", localSessionId).execute();
185723
185774
  },
185775
+ getNotificationSyncCandidates: async ({ now: now2, includeExpired }) => {
185776
+ let query = kdb.selectFrom("remote_session_mappings").selectAll();
185777
+ if (!includeExpired) {
185778
+ query = query.where("notification_watch_until", ">", now2);
185779
+ }
185780
+ const rows = await query.orderBy("local_session_id", "asc").execute();
185781
+ return rows.map(mapRemoteSessionMapping);
185782
+ },
185783
+ // The cursor is deleted with the mapping: without a mapping the front has
185784
+ // no local target for the worker's events, so a stale cursor would only
185785
+ // suppress a legitimate re-mapping's from_start replay.
185724
185786
  delete: async (localSessionId) => {
185725
- await kdb.deleteFrom("remote_session_mappings").where("local_session_id", "=", localSessionId).execute();
185787
+ await kdb.transaction().execute(async (trx) => {
185788
+ const row = await trx.selectFrom("remote_session_mappings").select(["remote_server_id", "remote_session_id"]).where("local_session_id", "=", localSessionId).executeTakeFirst();
185789
+ await trx.deleteFrom("remote_session_mappings").where("local_session_id", "=", localSessionId).execute();
185790
+ if (row) {
185791
+ await trx.deleteFrom("notification_sync_cursors").where("remote_server_id", "=", row.remote_server_id).where("remote_session_id", "=", row.remote_session_id).execute();
185792
+ }
185793
+ });
185726
185794
  },
185727
185795
  isTitleResolved: async (localSessionId) => {
185728
185796
  const row = await kdb.selectFrom("remote_session_mappings").select("title_resolved").where("local_session_id", "=", localSessionId).executeTakeFirst();
@@ -186248,6 +186316,18 @@ var createWorkflowRunRepos = (kdb) => ({
186248
186316
  transition: async (id, from, to, patch) => {
186249
186317
  const result = await kdb.updateTable("workflow_runs").set({ ...patch ?? {}, status: to, updated_at: sql`datetime('now')` }).where("id", "=", id).where("status", "=", from).executeTakeFirst();
186250
186318
  return (result.numUpdatedRows ?? 0n) > 0n;
186319
+ },
186320
+ // Guarded update FIRST, then the outbox insert conditioned on it having
186321
+ // applied: that ordering is what makes a lost CAS silent. Both statements
186322
+ // share one transaction so a crash between them can't leave a notified
186323
+ // transition unpersisted (or vice versa).
186324
+ transitionWithOutbox: async (id, from, to, patch, outbox) => {
186325
+ return kdb.transaction().execute(async (trx) => {
186326
+ const result = await trx.updateTable("workflow_runs").set({ ...patch ?? {}, status: to, updated_at: sql`datetime('now')` }).where("id", "=", id).where("status", "=", from).executeTakeFirst();
186327
+ if ((result.numUpdatedRows ?? 0n) === 0n) return false;
186328
+ await trx.insertInto("notification_outbox").values(outbox).onConflict((oc) => oc.column("id").doNothing()).execute();
186329
+ return true;
186330
+ });
186251
186331
  }
186252
186332
  }
186253
186333
  });
@@ -186277,6 +186357,157 @@ var createTurnSnapshotRepos = (kdb) => ({
186277
186357
  }
186278
186358
  });
186279
186359
 
186360
+ // src/storage/repositories/notifications.ts
186361
+ var mapOutbox = (row) => ({
186362
+ seq: row.seq,
186363
+ id: row.id,
186364
+ kind: row.kind,
186365
+ project_id: row.project_id,
186366
+ branch: row.branch,
186367
+ session_id: row.session_id,
186368
+ workflow_run_id: row.workflow_run_id,
186369
+ created_at: row.created_at
186370
+ });
186371
+ var mapNotification = (row) => ({
186372
+ id: row.id,
186373
+ user_id: row.user_id,
186374
+ kind: row.kind,
186375
+ project_id: row.project_id,
186376
+ branch: row.branch,
186377
+ session_id: row.session_id,
186378
+ workflow_run_id: row.workflow_run_id,
186379
+ title: row.title,
186380
+ body: row.body,
186381
+ created_at: row.created_at,
186382
+ read_at: row.read_at
186383
+ });
186384
+ var createNotificationRepos = (kdb) => ({
186385
+ notificationOutbox: {
186386
+ // ON CONFLICT(id) DO NOTHING is what makes retrying the *business*
186387
+ // transition safe: the milestone id is derived from durable state
186388
+ // (session + turn_end index, or workflow run id), so a retried transaction
186389
+ // re-derives the same id and the second insert is a no-op rather than a
186390
+ // second ding. `inserted: false` still returns the existing seq so callers
186391
+ // that want to trigger a drain can do so either way.
186392
+ insert: async (event) => {
186393
+ const result = await kdb.insertInto("notification_outbox").values(event).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186394
+ const inserted = (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186395
+ const row = await kdb.selectFrom("notification_outbox").select("seq").where("id", "=", event.id).executeTakeFirst();
186396
+ return { inserted, seq: row?.seq ?? null };
186397
+ },
186398
+ listAfter: async (afterSeq, limit) => {
186399
+ const rows = await kdb.selectFrom("notification_outbox").selectAll().where("seq", ">", afterSeq).orderBy("seq", "asc").limit(limit).execute();
186400
+ return rows.map(mapOutbox);
186401
+ },
186402
+ listBySessionAfter: async (sessionId, afterSeq, limit) => {
186403
+ const rows = await kdb.selectFrom("notification_outbox").selectAll().where("session_id", "=", sessionId).where("seq", ">", afterSeq).orderBy("seq", "asc").limit(limit).execute();
186404
+ return rows.map(mapOutbox);
186405
+ },
186406
+ headBySession: async (sessionId) => {
186407
+ const row = await kdb.selectFrom("notification_outbox").select(kdb.fn.max("seq").as("head")).where("session_id", "=", sessionId).executeTakeFirst();
186408
+ return row?.head ?? 0;
186409
+ },
186410
+ head: async () => {
186411
+ const row = await kdb.selectFrom("notification_outbox").select(kdb.fn.max("seq").as("head")).executeTakeFirst();
186412
+ return row?.head ?? 0;
186413
+ },
186414
+ pruneOlderThan: async (cutoffMs) => {
186415
+ await kdb.deleteFrom("notification_outbox").where("created_at", "<", cutoffMs).execute();
186416
+ }
186417
+ },
186418
+ notifications: {
186419
+ insert: async (notification) => {
186420
+ const result = await kdb.insertInto("notifications").values(notification).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186421
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186422
+ },
186423
+ // One transaction on purpose. Insert-then-crash-then-replay is safe (the id
186424
+ // unique constraint absorbs it), but cursor-advance-then-crash would lose
186425
+ // the notification permanently — so the two writes must commit together.
186426
+ importRemote: async ({ notification, remoteServerId, remoteSessionId, seq }) => {
186427
+ return kdb.transaction().execute(async (trx) => {
186428
+ const result = await trx.insertInto("notifications").values(notification).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186429
+ await trx.insertInto("notification_sync_cursors").values({
186430
+ remote_server_id: remoteServerId,
186431
+ remote_session_id: remoteSessionId,
186432
+ last_seq: seq,
186433
+ updated_at: Date.now()
186434
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doUpdateSet({
186435
+ // MAX(existing, incoming) — pages can be retried out of order after
186436
+ // a transient remote error, and a cursor that moved backward would
186437
+ // re-import (and re-ding) everything after it.
186438
+ last_seq: sql`MAX(notification_sync_cursors.last_seq, excluded.last_seq)`,
186439
+ updated_at: Date.now()
186440
+ })).execute();
186441
+ return { inserted: (result.numInsertedOrUpdatedRows ?? 0n) > 0n };
186442
+ });
186443
+ },
186444
+ listForUser: async (userId, opts) => {
186445
+ let query = kdb.selectFrom("notifications").selectAll().where("user_id", "=", userId);
186446
+ if (opts.unreadOnly) query = query.where("read_at", "is", null);
186447
+ const rows = await query.orderBy("created_at", "desc").orderBy("id", "desc").limit(opts.limit).execute();
186448
+ return rows.map(mapNotification);
186449
+ },
186450
+ // The user_id predicate is load-bearing authorization, not a filter: a
186451
+ // caller must not be able to mutate — or learn the existence of — another
186452
+ // tenant's notification by guessing its (deterministic, guessable) id.
186453
+ markRead: async (id, userId) => {
186454
+ const result = await kdb.updateTable("notifications").set({ read_at: Date.now() }).where("id", "=", id).where("user_id", "=", userId).where("read_at", "is", null).executeTakeFirst();
186455
+ if ((result.numUpdatedRows ?? 0n) > 0n) return true;
186456
+ const existing = await kdb.selectFrom("notifications").select("id").where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
186457
+ return existing !== void 0;
186458
+ },
186459
+ markAllRead: async (userId) => {
186460
+ await kdb.updateTable("notifications").set({ read_at: Date.now() }).where("user_id", "=", userId).where("read_at", "is", null).execute();
186461
+ },
186462
+ // Unread rows are never deleted — an unread milestone is the whole point of
186463
+ // the feature. Read history is capped per user so it can't grow forever.
186464
+ cleanup: async (keepRead) => {
186465
+ const users = await kdb.selectFrom("notifications").select("user_id").where("read_at", "is not", null).groupBy("user_id").execute();
186466
+ for (const { user_id } of users) {
186467
+ const keep = await kdb.selectFrom("notifications").select("id").where("user_id", "=", user_id).where("read_at", "is not", null).orderBy("created_at", "desc").orderBy("id", "desc").limit(keepRead).execute();
186468
+ let query = kdb.deleteFrom("notifications").where("user_id", "=", user_id).where("read_at", "is not", null);
186469
+ if (keep.length > 0) {
186470
+ query = query.where("id", "not in", keep.map((r) => r.id));
186471
+ }
186472
+ await query.execute();
186473
+ }
186474
+ }
186475
+ },
186476
+ notificationSyncCursors: {
186477
+ get: async (remoteServerId, remoteSessionId) => {
186478
+ const row = await kdb.selectFrom("notification_sync_cursors").select("last_seq").where("remote_server_id", "=", remoteServerId).where("remote_session_id", "=", remoteSessionId).executeTakeFirst();
186479
+ return row?.last_seq;
186480
+ },
186481
+ getMany: async (remoteServerId, remoteSessionIds) => {
186482
+ if (remoteSessionIds.length === 0) return /* @__PURE__ */ new Map();
186483
+ const rows = await kdb.selectFrom("notification_sync_cursors").select(["remote_session_id", "last_seq"]).where("remote_server_id", "=", remoteServerId).where("remote_session_id", "in", remoteSessionIds).execute();
186484
+ return new Map(rows.map((r) => [r.remote_session_id, r.last_seq]));
186485
+ },
186486
+ set: async (remoteServerId, remoteSessionId, lastSeq) => {
186487
+ await kdb.insertInto("notification_sync_cursors").values({
186488
+ remote_server_id: remoteServerId,
186489
+ remote_session_id: remoteSessionId,
186490
+ last_seq: lastSeq,
186491
+ updated_at: Date.now()
186492
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doUpdateSet({
186493
+ last_seq: sql`MAX(notification_sync_cursors.last_seq, excluded.last_seq)`,
186494
+ updated_at: Date.now()
186495
+ })).execute();
186496
+ },
186497
+ // DO NOTHING, not DO UPDATE: see the contract note in types.ts for why a
186498
+ // baseline must never overwrite an existing cursor.
186499
+ initializeIfAbsent: async (remoteServerId, remoteSessionId, lastSeq) => {
186500
+ const result = await kdb.insertInto("notification_sync_cursors").values({
186501
+ remote_server_id: remoteServerId,
186502
+ remote_session_id: remoteSessionId,
186503
+ last_seq: lastSeq,
186504
+ updated_at: Date.now()
186505
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doNothing()).executeTakeFirst();
186506
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186507
+ }
186508
+ }
186509
+ });
186510
+
186280
186511
  // src/storage/sqlite.ts
186281
186512
  var createDatabase = (dbPath) => {
186282
186513
  const db = new Database(dbPath);
@@ -186975,7 +187206,66 @@ var createDatabase = (dbPath) => {
186975
187206
  PRIMARY KEY (session_id, turn_end_index),
186976
187207
  FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
186977
187208
  );
187209
+
187210
+ -- Durable attention-milestone outbox of THIS server. Written in the same
187211
+ -- transaction as the state that proves the milestone (a turn_end entry, a
187212
+ -- workflow status transition), so a crash can never leave a completed turn
187213
+ -- without its notification. Deliberately carries no title/body: the
187214
+ -- user-facing server generates copy at import time from its own session
187215
+ -- mapping, so a stale worker-side title never enters the wire protocol.
187216
+ -- No FK to agent_sessions: an outbox row must outlive deletion of the
187217
+ -- session it describes (deleting a session mid-sync must not silently drop
187218
+ -- an already-produced milestone). seq is the cursor, not created_at.
187219
+ CREATE TABLE IF NOT EXISTS notification_outbox (
187220
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
187221
+ id TEXT NOT NULL UNIQUE,
187222
+ kind TEXT NOT NULL,
187223
+ project_id TEXT NOT NULL,
187224
+ branch TEXT,
187225
+ session_id TEXT NOT NULL,
187226
+ workflow_run_id TEXT,
187227
+ created_at INTEGER NOT NULL
187228
+ );
187229
+
187230
+ CREATE INDEX IF NOT EXISTS idx_notification_outbox_session_seq
187231
+ ON notification_outbox(session_id, seq);
187232
+
187233
+ -- User-scoped notification inbox of the user-facing server. The id is the
187234
+ -- deterministic milestone id (remote rows namespaced
187235
+ -- remote:{serverId}:{outboxEventId}), which is what makes replay after a
187236
+ -- crash-before-cursor-advance a no-op instead of a duplicate ding.
187237
+ CREATE TABLE IF NOT EXISTS notifications (
187238
+ id TEXT PRIMARY KEY,
187239
+ user_id TEXT NOT NULL,
187240
+ kind TEXT NOT NULL,
187241
+ project_id TEXT NOT NULL,
187242
+ branch TEXT,
187243
+ session_id TEXT,
187244
+ workflow_run_id TEXT,
187245
+ title TEXT NOT NULL,
187246
+ body TEXT,
187247
+ created_at INTEGER NOT NULL,
187248
+ read_at INTEGER
187249
+ );
187250
+
187251
+ CREATE INDEX IF NOT EXISTS idx_notifications_user_created
187252
+ ON notifications(user_id, created_at DESC);
187253
+
187254
+ CREATE TABLE IF NOT EXISTS notification_sync_cursors (
187255
+ remote_server_id TEXT NOT NULL,
187256
+ remote_session_id TEXT NOT NULL,
187257
+ last_seq INTEGER NOT NULL,
187258
+ updated_at INTEGER NOT NULL,
187259
+ PRIMARY KEY (remote_server_id, remote_session_id)
187260
+ );
186978
187261
  `);
187262
+ const remoteMappingNotifyInfo = db.prepare("PRAGMA table_info(remote_session_mappings)").all();
187263
+ if (!remoteMappingNotifyInfo.some((col) => col.name === "notification_sync_start")) {
187264
+ db.exec("ALTER TABLE remote_session_mappings ADD COLUMN notification_sync_start TEXT NOT NULL DEFAULT 'from_now'");
187265
+ }
187266
+ if (!remoteMappingNotifyInfo.some((col) => col.name === "notification_watch_until")) {
187267
+ db.exec("ALTER TABLE remote_session_mappings ADD COLUMN notification_watch_until INTEGER");
187268
+ }
186979
187269
  const sessionSearchCacheInfo = db.prepare("PRAGMA table_info(session_search_cache)").all();
186980
187270
  if (!sessionSearchCacheInfo.some((col) => col.name === "written_at")) {
186981
187271
  db.exec("ALTER TABLE session_search_cache ADD COLUMN written_at INTEGER");
@@ -187022,6 +187312,7 @@ var createSqliteStorage = async (dbPath) => {
187022
187312
  ...createSearchCacheRepos(kdb, h),
187023
187313
  ...createWorkflowRunRepos(kdb),
187024
187314
  ...createTurnSnapshotRepos(kdb),
187315
+ ...createNotificationRepos(kdb),
187025
187316
  close: async () => {
187026
187317
  await kdb.destroy();
187027
187318
  }
@@ -187038,7 +187329,7 @@ import path17 from "path";
187038
187329
  import { fileURLToPath } from "url";
187039
187330
 
187040
187331
  // src/plugins/shared-services.ts
187041
- var import_fastify_plugin = __toESM(require_plugin2(), 1);
187332
+ var import_fastify_plugin2 = __toESM(require_plugin2(), 1);
187042
187333
 
187043
187334
  // src/process-manager.ts
187044
187335
  import { spawn } from "child_process";
@@ -202016,6 +202307,57 @@ import { spawn as spawn3 } from "child_process";
202016
202307
  import { randomUUID } from "crypto";
202017
202308
  import { existsSync as existsSync3 } from "fs";
202018
202309
 
202310
+ // src/notification-milestones.ts
202311
+ var sessionResultReadyId = (sessionId, turnEndEntryIndex) => `session:${sessionId}:turn:${turnEndEntryIndex}:result-ready`;
202312
+ var sessionFailedId = (sessionId, turnEndEntryIndex) => `session:${sessionId}:turn:${turnEndEntryIndex}:failed`;
202313
+ var reviewReadyId = (workflowRunId) => `workflow:${workflowRunId}:review-ready`;
202314
+ var workflowFailedId = (workflowRunId, stateVersion) => `workflow:${workflowRunId}:failed:${stateVersion}`;
202315
+ function findTurnOpeningUserEntry(entries, beforeIndex) {
202316
+ let opening;
202317
+ for (let i = Math.min(beforeIndex, entries.length) - 1; i >= 0; i--) {
202318
+ const entry = entries[i];
202319
+ if (!entry) continue;
202320
+ if (entry.type === "turn_end") break;
202321
+ if (entry.type === "user") opening = entry;
202322
+ }
202323
+ return opening;
202324
+ }
202325
+ function resolveNotificationDisposition(openingUserEntry) {
202326
+ if (openingUserEntry?.type !== "user") return "result";
202327
+ if (openingUserEntry.notificationDisposition) return openingUserEntry.notificationDisposition;
202328
+ return openingUserEntry.origin === "workflow" ? "internal" : "result";
202329
+ }
202330
+ var FAILURE_OUTCOMES = /* @__PURE__ */ new Set([
202331
+ "failed",
202332
+ "process_exit",
202333
+ "server_restart"
202334
+ ]);
202335
+ function sessionMilestoneForTurnEnd(opts) {
202336
+ if (opts.disposition !== "result") return void 0;
202337
+ const base = {
202338
+ project_id: opts.projectId,
202339
+ branch: opts.branch,
202340
+ session_id: opts.sessionId,
202341
+ workflow_run_id: null,
202342
+ created_at: opts.createdAt
202343
+ };
202344
+ if (opts.outcome === "completed") {
202345
+ return {
202346
+ ...base,
202347
+ id: sessionResultReadyId(opts.sessionId, opts.entryIndex),
202348
+ kind: "session_result_ready"
202349
+ };
202350
+ }
202351
+ if (FAILURE_OUTCOMES.has(opts.outcome)) {
202352
+ return {
202353
+ ...base,
202354
+ id: sessionFailedId(opts.sessionId, opts.entryIndex),
202355
+ kind: "session_failed"
202356
+ };
202357
+ }
202358
+ return void 0;
202359
+ }
202360
+
202019
202361
  // src/utils/cross-remote-token.ts
202020
202362
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
202021
202363
  var CROSS_REMOTE_SECRET_SETTING = "cross_remote_token_secret";
@@ -224295,6 +224637,36 @@ var AgentSessionManager = class {
224295
224637
  setEventBus(eventBus) {
224296
224638
  this.eventBus = eventBus;
224297
224639
  }
224640
+ /**
224641
+ * Injected by shared-services: nudges NotificationService to drain the local
224642
+ * outbox right after a milestone lands, so the bell is fast in the common
224643
+ * case. Purely a latency optimization — correctness comes from the periodic
224644
+ * and startup drains, because this hook can't fire if the process dies
224645
+ * between the commit and the import.
224646
+ */
224647
+ setMilestoneListener(listener) {
224648
+ this.onMilestoneCreated = listener;
224649
+ }
224650
+ onMilestoneCreated = null;
224651
+ /**
224652
+ * Decide the disposition to persist on an outgoing user turn.
224653
+ *
224654
+ * Ordinary user input defaults to `result`. Workflow callers must be explicit:
224655
+ * `origin: "workflow"` alone is NOT enough to infer `internal`, because
224656
+ * approved review feedback is also a workflow-authored turn yet starts a
224657
+ * user-visible source result. An omission is therefore a caller bug — warn
224658
+ * loudly rather than silently picking a disposition that suppresses a real
224659
+ * notification.
224660
+ */
224661
+ resolveOutgoingDisposition(sessionId, opts) {
224662
+ if (opts?.notificationDisposition) return opts.notificationDisposition;
224663
+ if (opts?.origin === "workflow") {
224664
+ console.warn(
224665
+ `[AgentSession] workflow-origin turn for ${sessionId} carries no explicit notificationDisposition; defaulting to "result"`
224666
+ );
224667
+ }
224668
+ return "result";
224669
+ }
224298
224670
  /**
224299
224671
  * Injected by shared-services: lets commitCompletion mark taskCompleted WS
224300
224672
  * frames whose completion the local WorkflowEngine claims (reviewer
@@ -224570,7 +224942,8 @@ var AgentSessionManager = class {
224570
224942
  bgSpawnHintsThisTurn: 0,
224571
224943
  taskStartedThisTurn: 0,
224572
224944
  lastActiveAt: Date.now(),
224573
- turnOpenSince: null
224945
+ turnOpenSince: null,
224946
+ turnDisposition: null
224574
224947
  };
224575
224948
  this.sessions.set(sessionId, session);
224576
224949
  const provider = getProvider(agentType);
@@ -225139,14 +225512,22 @@ Details:
225139
225512
  ${details}`;
225140
225513
  return msg;
225141
225514
  }
225515
+ /**
225516
+ * Allocate the next entry index, record the message in the in-memory store,
225517
+ * and build its ADD replay patch. Shared by `pushEntry` and `pushTurnEnd` so
225518
+ * the two persistence paths can't drift on index allocation or patch shape.
225519
+ */
225520
+ stageEntry(session, message) {
225521
+ const index = session.store.indexProvider.next();
225522
+ session.store.entries[index] = message;
225523
+ const patch = ConversationPatch.addEntry(index, message);
225524
+ session.store.patches.push(patch);
225525
+ return { index, patch };
225526
+ }
225142
225527
  async pushEntry(sessionId, message, broadcast = true, userId = "local") {
225143
225528
  const session = this.sessions.get(sessionId);
225144
225529
  if (!session) return -1;
225145
- const { store } = session;
225146
- const index = store.indexProvider.next();
225147
- store.entries[index] = message;
225148
- const patch = ConversationPatch.addEntry(index, message);
225149
- store.patches.push(patch);
225530
+ const { index, patch } = this.stageEntry(session, message);
225150
225531
  if (!session.skipDb && message.type !== "assistant") {
225151
225532
  await this.persistEntry(session, index, message, userId);
225152
225533
  }
@@ -225155,6 +225536,60 @@ ${details}`;
225155
225536
  }
225156
225537
  return index;
225157
225538
  }
225539
+ /**
225540
+ * Persist a `turn_end` together with the attention milestone it earns.
225541
+ *
225542
+ * Deliberately NOT routed through `pushEntry`: the milestone id embeds the
225543
+ * turn_end's entry index, so the index has to be allocated before the write,
225544
+ * and the write itself has to be the atomic turn-end/outbox operation rather
225545
+ * than a plain entry upsert. Ordering matches `pushEntry` (persist, then
225546
+ * broadcast) so the existing turn_end-before-status contract still holds.
225547
+ *
225548
+ * `entryIndexOverride` re-persists an already-staged boundary (crash-repair
225549
+ * replay); without it a fresh index is allocated.
225550
+ */
225551
+ async pushTurnEnd(session, outcome, disposition, endedAt, durationMs, entryIndexOverride) {
225552
+ const message = {
225553
+ type: "turn_end",
225554
+ timestamp: endedAt,
225555
+ ...durationMs !== void 0 ? { durationMs } : {},
225556
+ outcome,
225557
+ notificationDisposition: disposition
225558
+ };
225559
+ let index;
225560
+ let patch;
225561
+ if (entryIndexOverride !== void 0) {
225562
+ index = entryIndexOverride;
225563
+ patch = ConversationPatch.addEntry(index, message);
225564
+ } else {
225565
+ ({ index, patch } = this.stageEntry(session, message));
225566
+ }
225567
+ if (!session.skipDb) {
225568
+ const outbox = sessionMilestoneForTurnEnd({
225569
+ sessionId: session.id,
225570
+ projectId: session.projectId,
225571
+ branch: session.branch,
225572
+ entryIndex: index,
225573
+ outcome,
225574
+ disposition,
225575
+ createdAt: endedAt
225576
+ });
225577
+ try {
225578
+ await this.storage.agentSessions.upsertTurnEndWithOutbox({
225579
+ sessionId: session.id,
225580
+ entryIndex: index,
225581
+ entryData: JSON.stringify(message),
225582
+ outbox
225583
+ });
225584
+ await this.storage.agentSessions.touchUpdatedAt(session.id);
225585
+ if (outbox) this.onMilestoneCreated?.();
225586
+ } catch (error48) {
225587
+ console.error(`[AgentSession] Failed to persist turn_end ${index} for ${session.id}:`, error48);
225588
+ }
225589
+ }
225590
+ if (entryIndexOverride === void 0) this.broadcastPatch(session.id, patch);
225591
+ return index;
225592
+ }
225158
225593
  /**
225159
225594
  * Persist a single entry to the database
225160
225595
  */
@@ -225193,18 +225628,24 @@ ${details}`;
225193
225628
  }
225194
225629
  }
225195
225630
  /**
225196
- * Close the open turn with a persisted turn_end stop-point entry.
225631
+ * Close the open turn with a persisted turn_end stop-point entry, plus the
225632
+ * attention milestone the turn's disposition earns (written in the same
225633
+ * storage transaction — see pushTurnEnd).
225634
+ *
225197
225635
  * turn_end entries are constructed ONLY here and in repairInterruptedTurn
225198
225636
  * (restore path). Wall clock only — see design doc for why the CLI's
225199
- * payload.duration_ms is not used. Rides the normal best-effort entry
225200
- * persistence on purpose (no strict path — design decision).
225637
+ * payload.duration_ms is not used.
225201
225638
  */
225202
225639
  async endActiveTurn(session, outcome) {
225203
225640
  if (session.turnOpenSince === null) return null;
225204
225641
  const endedAt = Date.now();
225205
225642
  const durationMs = endedAt - session.turnOpenSince;
225206
- const index = await this.pushEntry(session.id, { type: "turn_end", timestamp: endedAt, durationMs, outcome }, true);
225643
+ const disposition = session.turnDisposition ?? resolveNotificationDisposition(
225644
+ findTurnOpeningUserEntry(session.store.entries, session.store.entries.length)
225645
+ );
225646
+ const index = await this.pushTurnEnd(session, outcome, disposition, endedAt, durationMs);
225207
225647
  session.turnOpenSince = null;
225648
+ session.turnDisposition = null;
225208
225649
  if (!session.skipDb && index >= 0) {
225209
225650
  try {
225210
225651
  const project = await this.storage.projects.getById(session.projectId);
@@ -225223,12 +225664,13 @@ ${details}`;
225223
225664
  async sendUserMessage(sessionId, content, projectPath, userId = "local", opts) {
225224
225665
  const session = this.sessions.get(sessionId);
225225
225666
  if (!session) return false;
225667
+ const disposition = this.resolveOutgoingDisposition(sessionId, opts);
225226
225668
  if (session.dormant) {
225227
225669
  if (!projectPath) {
225228
225670
  console.error(`[AgentSession] Cannot wake dormant session ${sessionId} without projectPath`);
225229
225671
  return false;
225230
225672
  }
225231
- await this.wakeDormantSession(session, projectPath, content, userId, opts?.origin);
225673
+ await this.wakeDormantSession(session, projectPath, content, userId, opts?.origin, disposition);
225232
225674
  return true;
225233
225675
  }
225234
225676
  if (!session.process?.stdin) {
@@ -225248,7 +225690,8 @@ ${details}`;
225248
225690
  type: "user",
225249
225691
  content,
225250
225692
  timestamp: Date.now(),
225251
- ...opts?.origin ? { origin: opts.origin } : {}
225693
+ ...opts?.origin ? { origin: opts.origin } : {},
225694
+ notificationDisposition: disposition
225252
225695
  }, true, userId);
225253
225696
  try {
225254
225697
  const provider = getProvider(session.agentType);
@@ -225257,14 +225700,20 @@ ${details}`;
225257
225700
  console.warn(
225258
225701
  `[AgentSession] sendUserMessage: provider buffered the input for ${session.agentType} session ${sessionId} \u2014 no stdin payload yet`
225259
225702
  );
225260
- if (session.turnOpenSince === null) session.turnOpenSince = Date.now();
225703
+ if (session.turnOpenSince === null) {
225704
+ session.turnOpenSince = Date.now();
225705
+ session.turnDisposition = disposition;
225706
+ }
225261
225707
  return true;
225262
225708
  }
225263
225709
  console.log(
225264
225710
  `[AgentSession] sendUserMessage: wrote ${formatted.length}B to ${session.agentType} stdin (session=${sessionId})`
225265
225711
  );
225266
225712
  session.process.stdin.write(formatted);
225267
- if (session.turnOpenSince === null) session.turnOpenSince = Date.now();
225713
+ if (session.turnOpenSince === null) {
225714
+ session.turnOpenSince = Date.now();
225715
+ session.turnDisposition = disposition;
225716
+ }
225268
225717
  return true;
225269
225718
  } catch (error48) {
225270
225719
  console.error(`[AgentSession] Failed to send message:`, error48);
@@ -225691,7 +226140,7 @@ ${details}`;
225691
226140
  /**
225692
226141
  * Wake a dormant session: spawn process, send full context + user message
225693
226142
  */
225694
- async wakeDormantSession(session, projectPath, userMessage, userId = "local", origin) {
226143
+ async wakeDormantSession(session, projectPath, userMessage, userId = "local", origin, notificationDisposition = "result") {
225695
226144
  console.log(`[AgentSession] Waking dormant session ${session.id}`);
225696
226145
  await this.ensureResidentCapacity(
225697
226146
  { projectId: session.projectId, branch: session.branch },
@@ -225712,9 +226161,11 @@ ${details}`;
225712
226161
  type: "user",
225713
226162
  content: userMessage,
225714
226163
  timestamp: Date.now(),
225715
- ...origin ? { origin } : {}
226164
+ ...origin ? { origin } : {},
226165
+ notificationDisposition
225716
226166
  }, true, userId);
225717
226167
  session.turnOpenSince = Date.now();
226168
+ session.turnDisposition = notificationDisposition;
225718
226169
  setTimeout(() => {
225719
226170
  const context2 = this.buildFullConversationContext(session.store.entries);
225720
226171
  if (context2) {
@@ -225781,7 +226232,8 @@ ${details}`;
225781
226232
  * this runs on server boot for every restored session and must never
225782
226233
  * throw into the restore path.
225783
226234
  */
225784
- async repairInterruptedTurn(sessionId, rows) {
226235
+ async repairInterruptedTurn(dbSession, rows) {
226236
+ const sessionId = dbSession.id;
225785
226237
  let landingType = null;
225786
226238
  for (let i = rows.length - 1; i >= 0; i--) {
225787
226239
  try {
@@ -225796,17 +226248,43 @@ ${details}`;
225796
226248
  if (landingType === null || landingType === "turn_end") return rows;
225797
226249
  const maxIndex = rows.reduce((m2, r) => Math.max(m2, r.entry_index), -1);
225798
226250
  const repairIndex = maxIndex + 1;
225799
- const repair = { type: "turn_end", timestamp: Date.now(), outcome: "server_restart" };
226251
+ const entries = [];
226252
+ for (const row of rows) {
226253
+ try {
226254
+ entries[row.entry_index] = JSON.parse(row.data);
226255
+ } catch {
226256
+ }
226257
+ }
226258
+ const disposition = resolveNotificationDisposition(
226259
+ findTurnOpeningUserEntry(entries, repairIndex)
226260
+ );
226261
+ const repair = {
226262
+ type: "turn_end",
226263
+ timestamp: Date.now(),
226264
+ outcome: "server_restart",
226265
+ notificationDisposition: disposition
226266
+ };
225800
226267
  const data = JSON.stringify(repair);
225801
- await this.storage.agentSessions.upsertEntry(sessionId, repairIndex, data);
225802
- console.log(`[AgentSession] Repaired interrupted turn for ${sessionId} (server_restart turn_end at ${repairIndex})`);
226268
+ const outbox = sessionMilestoneForTurnEnd({
226269
+ sessionId,
226270
+ projectId: dbSession.project_id,
226271
+ branch: dbSession.branch || null,
226272
+ entryIndex: repairIndex,
226273
+ outcome: "server_restart",
226274
+ disposition,
226275
+ createdAt: repair.timestamp
226276
+ });
226277
+ await this.storage.agentSessions.upsertTurnEndWithOutbox({
226278
+ sessionId,
226279
+ entryIndex: repairIndex,
226280
+ entryData: data,
226281
+ outbox
226282
+ });
226283
+ console.log(`[AgentSession] Repaired interrupted turn for ${sessionId} (server_restart turn_end at ${repairIndex}, disposition=${disposition})`);
225803
226284
  try {
225804
- const dbSession = await this.storage.agentSessions.getById(sessionId);
225805
- if (dbSession) {
225806
- const project = await this.storage.projects.getById(dbSession.project_id);
225807
- if (project?.path) {
225808
- await recordTurnSnapshot(this.storage, sessionId, repairIndex, resolveWorktreePath(project.path, dbSession.branch));
225809
- }
226285
+ const project = await this.storage.projects.getById(dbSession.project_id);
226286
+ if (project?.path) {
226287
+ await recordTurnSnapshot(this.storage, sessionId, repairIndex, resolveWorktreePath(project.path, dbSession.branch));
225810
226288
  }
225811
226289
  } catch (error48) {
225812
226290
  console.warn(`[AgentSession] Turn snapshot lookup failed for ${sessionId}@${repairIndex}:`, error48);
@@ -225825,7 +226303,7 @@ ${details}`;
225825
226303
  let entries = await this.storage.agentSessions.getEntries(dbSession.id);
225826
226304
  if (entries.length === 0) continue;
225827
226305
  if (dbSession.status === "running") {
225828
- entries = await this.repairInterruptedTurn(dbSession.id, entries);
226306
+ entries = await this.repairInterruptedTurn(dbSession, entries);
225829
226307
  }
225830
226308
  const store = this.rebuildStoreFromRows(entries, dbSession.id);
225831
226309
  const permissionMode = dbSession.permission_mode === "plan" ? "plan" : "edit";
@@ -225848,7 +226326,8 @@ ${details}`;
225848
226326
  bgSpawnHintsThisTurn: 0,
225849
226327
  taskStartedThisTurn: 0,
225850
226328
  lastActiveAt: Date.now(),
225851
- turnOpenSince: null
226329
+ turnOpenSince: null,
226330
+ turnDisposition: null
225852
226331
  };
225853
226332
  this.sessions.set(dbSession.id, runningSession);
225854
226333
  await this.storage.agentSessions.updateStatusPreservingTimestamp(dbSession.id, "stopped");
@@ -225950,6 +226429,7 @@ ${details}`;
225950
226429
  taskStartedThisTurn: 0,
225951
226430
  lastActiveAt: Date.now(),
225952
226431
  turnOpenSince: null,
226432
+ turnDisposition: null,
225953
226433
  crossRemoteMcp: opts.crossRemoteMcp
225954
226434
  };
225955
226435
  this.sessions.set(newId, branched);
@@ -226296,6 +226776,394 @@ function runUpdatedEventFromRemoteFrame(parsed, sessionId, remoteInfo) {
226296
226776
  return { type: "workflow:run-updated", projectId, branch: run2.branch, run: run2 };
226297
226777
  }
226298
226778
 
226779
+ // src/routes/notification-outbox-routes.ts
226780
+ var import_fastify_plugin = __toESM(require_plugin2(), 1);
226781
+ var MAX_SESSIONS_PER_REQUEST = 100;
226782
+ var MAX_EVENTS_PER_SESSION = 100;
226783
+ function validateOutboxQuery(body) {
226784
+ const raw = body?.sessions;
226785
+ if (!Array.isArray(raw)) return { ok: false, error: "sessions must be an array" };
226786
+ if (raw.length === 0) return { ok: false, error: "sessions must not be empty" };
226787
+ if (raw.length > MAX_SESSIONS_PER_REQUEST) {
226788
+ return { ok: false, error: `sessions must contain at most ${MAX_SESSIONS_PER_REQUEST} entries` };
226789
+ }
226790
+ const seen = /* @__PURE__ */ new Set();
226791
+ const sessions = [];
226792
+ for (const entry of raw) {
226793
+ if (typeof entry !== "object" || entry === null) return { ok: false, error: "each session must be an object" };
226794
+ const { sessionId, after, headOnly } = entry;
226795
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
226796
+ return { ok: false, error: "sessionId must be a non-empty string" };
226797
+ }
226798
+ if (typeof after !== "number" || !Number.isInteger(after) || after < 0) {
226799
+ return { ok: false, error: "after must be a non-negative integer" };
226800
+ }
226801
+ if (headOnly !== void 0 && typeof headOnly !== "boolean") {
226802
+ return { ok: false, error: "headOnly must be a boolean" };
226803
+ }
226804
+ if (seen.has(sessionId)) return { ok: false, error: `duplicate sessionId: ${sessionId}` };
226805
+ seen.add(sessionId);
226806
+ sessions.push({ sessionId, after, ...headOnly ? { headOnly: true } : {} });
226807
+ }
226808
+ const rawLimit = body?.limitPerSession;
226809
+ if (rawLimit !== void 0) {
226810
+ if (typeof rawLimit !== "number" || !Number.isInteger(rawLimit) || rawLimit <= 0) {
226811
+ return { ok: false, error: "limitPerSession must be a positive integer" };
226812
+ }
226813
+ if (rawLimit > MAX_EVENTS_PER_SESSION) {
226814
+ return { ok: false, error: `limitPerSession must be at most ${MAX_EVENTS_PER_SESSION}` };
226815
+ }
226816
+ }
226817
+ return {
226818
+ ok: true,
226819
+ sessions,
226820
+ limit: typeof rawLimit === "number" ? rawLimit : MAX_EVENTS_PER_SESSION
226821
+ };
226822
+ }
226823
+ var routes = async (fastify2) => {
226824
+ fastify2.post("/api/notification-outbox/query", async (req, reply) => {
226825
+ const authResult = requireAuth(req, reply);
226826
+ if (authResult === null) return;
226827
+ const validated = validateOutboxQuery(req.body ?? {});
226828
+ if (!validated.ok) return reply.code(400).send({ error: validated.error });
226829
+ const sessions = [];
226830
+ for (const request of validated.sessions) {
226831
+ const headCursor = await fastify2.storage.notificationOutbox.headBySession(request.sessionId);
226832
+ if (request.headOnly) {
226833
+ sessions.push({
226834
+ sessionId: request.sessionId,
226835
+ events: [],
226836
+ headCursor,
226837
+ nextCursor: headCursor,
226838
+ hasMore: false
226839
+ });
226840
+ continue;
226841
+ }
226842
+ const events = await fastify2.storage.notificationOutbox.listBySessionAfter(
226843
+ request.sessionId,
226844
+ request.after,
226845
+ validated.limit
226846
+ );
226847
+ const nextCursor = events.length > 0 ? events[events.length - 1].seq : request.after;
226848
+ sessions.push({
226849
+ sessionId: request.sessionId,
226850
+ events,
226851
+ headCursor,
226852
+ nextCursor,
226853
+ hasMore: nextCursor < headCursor
226854
+ });
226855
+ }
226856
+ return reply.code(200).send({ sessions });
226857
+ });
226858
+ };
226859
+ var notification_outbox_routes_default = (0, import_fastify_plugin.default)(routes, { name: "notification-outbox-routes" });
226860
+
226861
+ // src/remote-notification-sync.ts
226862
+ var OUTBOX_QUERY_PATH = "/api/notification-outbox/query";
226863
+ var QUERY_TIMEOUT_MS = 15e3;
226864
+ var WATCH_WINDOW_MS = 30 * 60 * 1e3;
226865
+ var WATCH_EXTEND_THROTTLE_MS = 5 * 60 * 1e3;
226866
+ var MAX_TRACKED_SESSIONS = 5e3;
226867
+ var SYNC_INTERVAL_MS = 6e4;
226868
+ var MAX_PAGES_PER_SYNC = 50;
226869
+ function localWorkflowRunId(remoteServerId, projectId, remoteRunId) {
226870
+ return `remote-${remoteServerId}-${projectId}-${remoteRunId}`;
226871
+ }
226872
+ function localNotificationId(remoteServerId, outboxEventId) {
226873
+ return `remote:${remoteServerId}:${outboxEventId}`;
226874
+ }
226875
+ var RemoteNotificationSync = class {
226876
+ storage;
226877
+ notificationService;
226878
+ reverseConnectManager;
226879
+ proxy;
226880
+ timer = null;
226881
+ stopped = false;
226882
+ /** Serializes sweeps so a came-online sweep can't interleave with the tick. */
226883
+ chain = Promise.resolve();
226884
+ /**
226885
+ * Remote sessions this front currently believes are RUNNING. They are polled
226886
+ * regardless of their persisted watch window: an agent turn can easily run
226887
+ * longer than WATCH_WINDOW_MS while emitting nothing the bus can see, and a
226888
+ * mapping that lapsed mid-turn would not be polled when the turn finally
226889
+ * produces its milestone.
226890
+ */
226891
+ activeRemoteSessions = /* @__PURE__ */ new Set();
226892
+ /** sessionId → last activity-driven watch extension (see WATCH_EXTEND_THROTTLE_MS). */
226893
+ lastWatchExtend = /* @__PURE__ */ new Map();
226894
+ constructor(deps) {
226895
+ this.storage = deps.storage;
226896
+ this.notificationService = deps.notificationService;
226897
+ this.reverseConnectManager = deps.reverseConnectManager;
226898
+ this.proxy = deps.proxy ?? proxyToRemoteAuto;
226899
+ }
226900
+ start() {
226901
+ if (this.timer || this.stopped) return;
226902
+ this.timer = setInterval(() => {
226903
+ this.enqueue(() => this.syncAll({ includeExpired: false }));
226904
+ }, SYNC_INTERVAL_MS);
226905
+ this.timer.unref?.();
226906
+ }
226907
+ shutdown() {
226908
+ this.stopped = true;
226909
+ if (this.timer) clearInterval(this.timer);
226910
+ this.timer = null;
226911
+ this.activeRemoteSessions.clear();
226912
+ this.lastWatchExtend.clear();
226913
+ }
226914
+ /**
226915
+ * Watch live remote activity (design §Mapping initialization and polling).
226916
+ *
226917
+ * The bus carries the remote stream's bridged `session:status` /
226918
+ * `session:taskCompleted` frames plus this front's own emits for remote
226919
+ * sessions, so it is the one place that sees a remote session start and stop
226920
+ * without threading a callback through the WebSocket plumbing.
226921
+ */
226922
+ setEventBus(bus) {
226923
+ bus.subscribe((event) => this.observeActivity(event));
226924
+ }
226925
+ observeActivity(event) {
226926
+ const sessionId = event.sessionId;
226927
+ if (!sessionId || !sessionId.startsWith("remote-")) return;
226928
+ if (event.type === "session:status") {
226929
+ if (event.status === "running") this.activeRemoteSessions.add(sessionId);
226930
+ else this.activeRemoteSessions.delete(sessionId);
226931
+ } else if (event.type === "session:taskCompleted" || event.type === "session:finished") {
226932
+ this.activeRemoteSessions.delete(sessionId);
226933
+ }
226934
+ const now2 = Date.now();
226935
+ const last = this.lastWatchExtend.get(sessionId) ?? 0;
226936
+ if (now2 - last < WATCH_EXTEND_THROTTLE_MS) return;
226937
+ if (this.lastWatchExtend.size >= MAX_TRACKED_SESSIONS) this.pruneWatchBookkeeping(now2);
226938
+ this.lastWatchExtend.set(sessionId, now2);
226939
+ void this.extendWatch(sessionId);
226940
+ }
226941
+ /** Drop throttle entries older than a full window — they can only re-extend. */
226942
+ pruneWatchBookkeeping(now2) {
226943
+ for (const [sessionId, at] of this.lastWatchExtend) {
226944
+ if (now2 - at > WATCH_WINDOW_MS) this.lastWatchExtend.delete(sessionId);
226945
+ }
226946
+ }
226947
+ /** Fire-and-forget sweep, queued behind any in-flight one. */
226948
+ enqueue(work) {
226949
+ if (this.stopped) return;
226950
+ this.chain = this.chain.then(work).catch((err) => console.warn("[RemoteNotifications] sync failed:", err));
226951
+ }
226952
+ /** Extend a mapping's periodic-poll window (create / send / workflow start / live activity). */
226953
+ async extendWatch(localSessionId) {
226954
+ await this.storage.remoteSessionMappings.extendNotificationWatch(localSessionId, Date.now() + WATCH_WINDOW_MS).catch((err) => console.warn(`[RemoteNotifications] extendWatch(${localSessionId}) failed:`, err));
226955
+ }
226956
+ /**
226957
+ * Called before this front starts a new turn on a remote session.
226958
+ *
226959
+ * For a `from_now` mapping that has never been initialized, this records the
226960
+ * worker's current head FIRST — otherwise the turn we are about to start could
226961
+ * finish, be seen as "at or below the head we later record", and be suppressed
226962
+ * as history. Returns false when the baseline could not be recorded, in which
226963
+ * case the caller must NOT start the turn.
226964
+ */
226965
+ async prepareForNewTurn(localSessionId) {
226966
+ const mapping = await this.storage.remoteSessionMappings.getByLocal(localSessionId);
226967
+ if (!mapping) return true;
226968
+ await this.extendWatch(localSessionId);
226969
+ if (mapping.notification_sync_start !== "from_now") return true;
226970
+ const existing = await this.storage.notificationSyncCursors.get(
226971
+ mapping.remote_server_id,
226972
+ mapping.remote_session_id
226973
+ );
226974
+ if (existing !== void 0) return true;
226975
+ const target = await this.resolveTarget(mapping);
226976
+ if (!target) {
226977
+ console.warn(`[RemoteNotifications] no remote link for ${localSessionId}; skipping baseline`);
226978
+ return true;
226979
+ }
226980
+ const result = await this.query(target, [{ sessionId: mapping.remote_session_id, after: 0, headOnly: true }]);
226981
+ if (!result.ok) {
226982
+ console.warn(
226983
+ `[RemoteNotifications] baseline for ${localSessionId} failed (status ${result.status}); refusing to start the turn`
226984
+ );
226985
+ return false;
226986
+ }
226987
+ const sessions = this.parseSessions(result);
226988
+ const head = sessions.find((s3) => s3.sessionId === mapping.remote_session_id)?.headCursor;
226989
+ if (head === void 0) {
226990
+ console.warn(`[RemoteNotifications] baseline response for ${localSessionId} omitted the session`);
226991
+ return false;
226992
+ }
226993
+ await this.storage.notificationSyncCursors.initializeIfAbsent(
226994
+ mapping.remote_server_id,
226995
+ mapping.remote_session_id,
226996
+ head
226997
+ );
226998
+ return true;
226999
+ }
227000
+ /** Sweep every mapped remote server. */
227001
+ async syncAll(opts) {
227002
+ const mappings = await this.candidates(opts);
227003
+ for (const [serverId, group2] of groupByServer(mappings)) {
227004
+ await this.syncMappings(serverId, group2);
227005
+ }
227006
+ }
227007
+ /** Sweep one server — used when a reverse connection comes online. */
227008
+ async syncServer(remoteServerId, opts) {
227009
+ const mappings = (await this.candidates(opts)).filter((m2) => m2.remote_server_id === remoteServerId);
227010
+ if (mappings.length > 0) await this.syncMappings(remoteServerId, mappings);
227011
+ }
227012
+ /**
227013
+ * Mappings this sweep should poll: the persisted watch set, plus any session
227014
+ * currently believed to be running.
227015
+ *
227016
+ * The union is what keeps a turn longer than WATCH_WINDOW_MS covered. Its
227017
+ * window is extended when the turn starts, but a long turn can go silent for
227018
+ * hours; without this the mapping would lapse out of the periodic set and its
227019
+ * eventual milestone would wait for a server restart or a remote reconnect.
227020
+ */
227021
+ async candidates(opts) {
227022
+ const mappings = await this.storage.remoteSessionMappings.getNotificationSyncCandidates({
227023
+ now: Date.now(),
227024
+ includeExpired: opts.includeExpired
227025
+ });
227026
+ if (opts.includeExpired || this.activeRemoteSessions.size === 0) return mappings;
227027
+ const present = new Set(mappings.map((m2) => m2.local_session_id));
227028
+ for (const localSessionId of this.activeRemoteSessions) {
227029
+ if (present.has(localSessionId)) continue;
227030
+ const mapping = await this.storage.remoteSessionMappings.getByLocal(localSessionId);
227031
+ if (mapping) mappings.push(mapping);
227032
+ }
227033
+ return mappings;
227034
+ }
227035
+ async syncMappings(remoteServerId, mappings) {
227036
+ for (let i = 0; i < mappings.length; i += MAX_SESSIONS_PER_REQUEST) {
227037
+ const chunk = mappings.slice(i, i + MAX_SESSIONS_PER_REQUEST);
227038
+ await this.syncChunk(remoteServerId, chunk).catch(
227039
+ (err) => console.warn(`[RemoteNotifications] chunk sync for ${remoteServerId} failed:`, err)
227040
+ );
227041
+ }
227042
+ }
227043
+ async syncChunk(remoteServerId, chunk) {
227044
+ const target = await this.resolveTarget(chunk[0]);
227045
+ if (!target) {
227046
+ console.warn(`[RemoteNotifications] no remote link for server ${remoteServerId}; skipping ${chunk.length} mapping(s)`);
227047
+ return;
227048
+ }
227049
+ let pending = new Map(chunk.map((m2) => [m2.remote_session_id, m2]));
227050
+ for (let page = 0; page < MAX_PAGES_PER_SYNC && pending.size > 0; page++) {
227051
+ const cursors = await this.storage.notificationSyncCursors.getMany(
227052
+ remoteServerId,
227053
+ [...pending.keys()]
227054
+ );
227055
+ const requests = [...pending.values()].map((mapping) => {
227056
+ const cursor = cursors.get(mapping.remote_session_id);
227057
+ if (mapping.notification_sync_start === "from_now" && cursor === void 0) {
227058
+ return { sessionId: mapping.remote_session_id, after: 0, headOnly: true };
227059
+ }
227060
+ return { sessionId: mapping.remote_session_id, after: cursor ?? 0 };
227061
+ });
227062
+ const result = await this.query(target, requests);
227063
+ if (!result.ok) {
227064
+ console.warn(`[RemoteNotifications] outbox query to ${remoteServerId} failed (status ${result.status})`);
227065
+ return;
227066
+ }
227067
+ const byId = new Map(this.parseSessions(result).map((s3) => [s3.sessionId, s3]));
227068
+ const next = /* @__PURE__ */ new Map();
227069
+ for (const request of requests) {
227070
+ const mapping = pending.get(request.sessionId);
227071
+ const session = byId.get(request.sessionId);
227072
+ if (!session) continue;
227073
+ if ("headOnly" in request && request.headOnly) {
227074
+ await this.storage.notificationSyncCursors.initializeIfAbsent(
227075
+ remoteServerId,
227076
+ mapping.remote_session_id,
227077
+ session.headCursor
227078
+ );
227079
+ continue;
227080
+ }
227081
+ const imported = await this.importSession(remoteServerId, mapping, session);
227082
+ if (imported && session.hasMore) next.set(request.sessionId, mapping);
227083
+ }
227084
+ pending = next;
227085
+ }
227086
+ }
227087
+ /**
227088
+ * Import one session's page. Returns false when the batch was rejected, in
227089
+ * which case its cursor is untouched.
227090
+ */
227091
+ async importSession(remoteServerId, mapping, session) {
227092
+ for (const event of session.events) {
227093
+ if (event.session_id !== mapping.remote_session_id) {
227094
+ console.warn(
227095
+ `[RemoteNotifications] ${remoteServerId}: event ${event.id} claims session ${event.session_id}, expected ${mapping.remote_session_id}; rejecting batch`
227096
+ );
227097
+ return false;
227098
+ }
227099
+ }
227100
+ const ownerId = await this.storage.projects.getOwnerId(mapping.project_id);
227101
+ if (ownerId === void 0) {
227102
+ console.warn(`[RemoteNotifications] project ${mapping.project_id} not found; skipping ${mapping.local_session_id}`);
227103
+ return false;
227104
+ }
227105
+ for (const event of session.events) {
227106
+ const notification = await this.notificationService.buildNotification(event, {
227107
+ id: localNotificationId(remoteServerId, event.id),
227108
+ userId: ownerId || "local",
227109
+ projectId: mapping.project_id,
227110
+ sessionId: mapping.local_session_id,
227111
+ workflowRunId: event.workflow_run_id ? localWorkflowRunId(remoteServerId, mapping.project_id, event.workflow_run_id) : null
227112
+ });
227113
+ const { inserted } = await this.storage.notifications.importRemote({
227114
+ notification,
227115
+ remoteServerId,
227116
+ remoteSessionId: mapping.remote_session_id,
227117
+ seq: event.seq
227118
+ });
227119
+ if (inserted) this.notificationService.emitCreated(notification);
227120
+ }
227121
+ if (session.events.length > 0) await this.extendWatch(mapping.local_session_id);
227122
+ return true;
227123
+ }
227124
+ async query(target, sessions) {
227125
+ return this.proxy(
227126
+ target.serverId,
227127
+ target.url,
227128
+ target.apiKey,
227129
+ "POST",
227130
+ OUTBOX_QUERY_PATH,
227131
+ { sessions, limitPerSession: MAX_EVENTS_PER_SESSION },
227132
+ { timeoutMs: QUERY_TIMEOUT_MS, reverseConnectManager: this.reverseConnectManager }
227133
+ );
227134
+ }
227135
+ parseSessions(result) {
227136
+ const sessions = result.data?.sessions;
227137
+ return Array.isArray(sessions) ? sessions : [];
227138
+ }
227139
+ /**
227140
+ * URL/apiKey come from project_remotes — the authoritative source, same as the
227141
+ * remoteSessionMap hydration in shared-services. Reverse-connect rows have an
227142
+ * empty URL and are routed by server id instead.
227143
+ */
227144
+ async resolveTarget(mapping) {
227145
+ const remote = await this.storage.projectRemotes.getByProjectAndServer(
227146
+ mapping.project_id,
227147
+ mapping.remote_server_id
227148
+ );
227149
+ if (!remote) return null;
227150
+ return {
227151
+ serverId: mapping.remote_server_id,
227152
+ url: remote.server_url ?? "",
227153
+ apiKey: remote.server_api_key || ""
227154
+ };
227155
+ }
227156
+ };
227157
+ function groupByServer(mappings) {
227158
+ const groups = /* @__PURE__ */ new Map();
227159
+ for (const mapping of mappings) {
227160
+ const existing = groups.get(mapping.remote_server_id);
227161
+ if (existing) existing.push(mapping);
227162
+ else groups.set(mapping.remote_server_id, [mapping]);
227163
+ }
227164
+ return groups;
227165
+ }
227166
+
226299
227167
  // src/remote-agent-sessions.ts
226300
227168
  async function createRemoteAgentSession(deps, params) {
226301
227169
  const { projectId, agentMode, remoteConfig, branch, permissionMode, agentType, force, userId } = params;
@@ -226332,7 +227200,8 @@ async function createRemoteAgentSession(deps, params) {
226332
227200
  deps.remoteSessionMap.delete(localSessionId);
226333
227201
  return { ok: false, status: 409, data: { error: "Remote returned an unexpected session id; upgrade the remote" } };
226334
227202
  }
226335
- await deps.remoteSessionMappings.upsert(localSessionId, projectId, agentMode, remoteSessionId, branch ?? null);
227203
+ await deps.remoteSessionMappings.upsert(localSessionId, projectId, agentMode, remoteSessionId, branch ?? null, "from_start");
227204
+ await deps.remoteSessionMappings.extendNotificationWatch(localSessionId, Date.now() + WATCH_WINDOW_MS).catch((err) => console.warn("[RemoteSession] notification watch extend failed:", err));
226336
227205
  await deps.storage.searchCache.noteSessionCreated({
226337
227206
  localSessionId,
226338
227207
  projectId,
@@ -228975,6 +229844,15 @@ var WorkflowError = class extends Error {
228975
229844
  }
228976
229845
  code;
228977
229846
  };
229847
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "cancelled", "failed"]);
229848
+ var REVIEWER_TURN = {
229849
+ origin: "workflow",
229850
+ notificationDisposition: "milestone-managed"
229851
+ };
229852
+ var FEEDBACK_TURN = {
229853
+ origin: "workflow",
229854
+ notificationDisposition: "result"
229855
+ };
228978
229856
  var MAX_CONTEXT_CHARS = 2e3;
228979
229857
  var MAX_SELF_REPORT_CHARS = 4e3;
228980
229858
  var SELF_REPORT_MIN_CHARS = 80;
@@ -229170,10 +230048,57 @@ var WorkflowEngine = class {
229170
230048
  if (participant.runId === runId) this.participants.delete(sid);
229171
230049
  }
229172
230050
  }
230051
+ /**
230052
+ * The single place a run becomes `failed`. Goes through a guarded transition
230053
+ * out of the run's CURRENT status rather than an unconditional status write,
230054
+ * so the failure milestone can only be created by the caller that actually
230055
+ * performed the transition — a run that concurrently completed or was
230056
+ * cancelled fails the CAS and writes nothing.
230057
+ *
230058
+ * The milestone targets the participant the user should inspect: the reviewer
230059
+ * when one exists, otherwise the source session (a run can fail before its
230060
+ * reviewer is ever created).
230061
+ */
229173
230062
  async failRun(run2, error48) {
229174
- const failed = await this.storage.workflowRuns.update(run2.id, { status: "failed", error: error48 });
230063
+ if (TERMINAL_STATUSES.has(run2.status)) return;
230064
+ const from = run2.status;
230065
+ const ok = await this.storage.workflowRuns.transitionWithOutbox(
230066
+ run2.id,
230067
+ from,
230068
+ "failed",
230069
+ { error: error48 },
230070
+ {
230071
+ // The state the run failed OUT OF: two distinct failures of one run are
230072
+ // distinct attention milestones, while a retried transition out of the
230073
+ // same state is not.
230074
+ id: workflowFailedId(run2.id, from),
230075
+ kind: "workflow_failed",
230076
+ project_id: run2.project_id,
230077
+ branch: run2.branch,
230078
+ session_id: run2.reviewer_session_id ?? run2.source_session_id,
230079
+ workflow_run_id: run2.id,
230080
+ created_at: Date.now()
230081
+ }
230082
+ );
230083
+ if (!ok) return;
230084
+ const failed = await this.storage.workflowRuns.getById(run2.id);
229175
230085
  if (failed) this.untrackRun(failed);
230086
+ this.onMilestoneCreated?.();
230087
+ }
230088
+ /** Test seam for the failure path (see failRun). */
230089
+ async failRunForTest(runId, error48) {
230090
+ const run2 = await this.storage.workflowRuns.getById(runId);
230091
+ if (run2) await this.failRun(run2, error48);
229176
230092
  }
230093
+ /**
230094
+ * Injected by shared-services: nudges NotificationService to drain the local
230095
+ * outbox promptly. Latency only — correctness rests on the periodic/startup
230096
+ * drains, since this can't fire if the process dies right after the commit.
230097
+ */
230098
+ setMilestoneListener(listener) {
230099
+ this.onMilestoneCreated = listener;
230100
+ }
230101
+ onMilestoneCreated = null;
229177
230102
  /** Sync check used by ChatSessionManager before waking the commander model. */
229178
230103
  shouldSuppressAgentEvent(sessionId) {
229179
230104
  return this.participants.get(sessionId)?.role === "reviewer";
@@ -229299,7 +230224,7 @@ var WorkflowEngine = class {
229299
230224
  reviewFocus: opts.reviewFocus ?? null,
229300
230225
  target
229301
230226
  });
229302
- const sent = await this.agentOps.sendUserMessage(opts.reviewerSessionId, prompt, opts.project.path, void 0, { origin: "workflow" }).catch(() => false);
230227
+ const sent = await this.agentOps.sendUserMessage(opts.reviewerSessionId, prompt, opts.project.path, void 0, REVIEWER_TURN).catch(() => false);
229303
230228
  if (!sent) {
229304
230229
  await this.failRun(run2, "\u5411\u4E0A\u6B21 reviewer \u6295\u9012\u590D\u5BA1\u4EFB\u52A1\u5931\u8D25");
229305
230230
  throw new WorkflowError("send-failed", "\u5411\u4E0A\u6B21 reviewer \u6295\u9012\u590D\u5BA1\u4EFB\u52A1\u5931\u8D25");
@@ -229344,13 +230269,9 @@ var WorkflowEngine = class {
229344
230269
  target,
229345
230270
  scope
229346
230271
  });
229347
- const sent = await this.agentOps.sendUserMessage(reviewerId, prompt, opts.project.path, void 0, { origin: "workflow" });
230272
+ const sent = await this.agentOps.sendUserMessage(reviewerId, prompt, opts.project.path, void 0, REVIEWER_TURN);
229348
230273
  if (!sent) {
229349
- const failed = await this.storage.workflowRuns.update(run2.id, {
229350
- status: "failed",
229351
- error: "\u5411 reviewer \u6295\u9012\u4EFB\u52A1\u5931\u8D25"
229352
- });
229353
- if (failed) this.untrackRun(failed);
230274
+ await this.failRun({ ...run2, reviewer_session_id: reviewerId }, "\u5411 reviewer \u6295\u9012\u4EFB\u52A1\u5931\u8D25");
229354
230275
  throw new WorkflowError("spawn-failed", "\u5411 reviewer \u6295\u9012\u4EFB\u52A1\u5931\u8D25");
229355
230276
  }
229356
230277
  const updated = await this.storage.workflowRuns.update(run2.id, { reviewer_session_id: reviewerId });
@@ -229359,11 +230280,7 @@ var WorkflowEngine = class {
229359
230280
  return updated;
229360
230281
  } catch (err) {
229361
230282
  if (err instanceof WorkflowError && err.code === "spawn-failed") throw err;
229362
- const failed = await this.storage.workflowRuns.update(run2.id, {
229363
- status: "failed",
229364
- error: `\u521B\u5EFA reviewer \u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`
229365
- });
229366
- if (failed) this.untrackRun(failed);
230283
+ await this.failRun(run2, `\u521B\u5EFA reviewer \u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`);
229367
230284
  throw new WorkflowError("spawn-failed", "\u521B\u5EFA reviewer session \u5931\u8D25");
229368
230285
  }
229369
230286
  } catch (err) {
@@ -229388,11 +230305,28 @@ var WorkflowEngine = class {
229388
230305
  }
229389
230306
  } catch {
229390
230307
  }
229391
- const ok = await this.storage.workflowRuns.transition(run2.id, "waiting_reviewer", "waiting_feedback", {
229392
- feedback_snapshot: feedback,
229393
- ...driftNote ? { error: driftNote } : {}
229394
- });
230308
+ const ok = await this.storage.workflowRuns.transitionWithOutbox(
230309
+ run2.id,
230310
+ "waiting_reviewer",
230311
+ "waiting_feedback",
230312
+ {
230313
+ feedback_snapshot: feedback,
230314
+ ...driftNote ? { error: driftNote } : {}
230315
+ },
230316
+ {
230317
+ id: reviewReadyId(run2.id),
230318
+ kind: "review_ready",
230319
+ project_id: run2.project_id,
230320
+ branch: run2.branch,
230321
+ // Target the reviewer: that's where the feedback and the
230322
+ // approve/discard controls are.
230323
+ session_id: event.sessionId,
230324
+ workflow_run_id: run2.id,
230325
+ created_at: Date.now()
230326
+ }
230327
+ );
229395
230328
  if (!ok) return;
230329
+ this.onMilestoneCreated?.();
229396
230330
  const updated = await this.storage.workflowRuns.getById(run2.id);
229397
230331
  if (updated) this.emitRunUpdated(updated);
229398
230332
  }
@@ -229409,7 +230343,7 @@ var WorkflowEngine = class {
229409
230343
  if (!claimed) throw new WorkflowError("bad-state", "run \u72B6\u6001\u5DF2\u53D8\u5316\uFF08\u53EF\u80FD\u5DF2\u88AB\u5904\u7406\uFF09");
229410
230344
  const feedback = editedPayload ?? run2.feedback_snapshot ?? "";
229411
230345
  const project = await this.storage.projects.getById(run2.project_id);
229412
- const ok = await this.agentOps.sendUserMessage(run2.source_session_id, buildFeedbackMessage(feedback), project?.path ?? void 0, void 0, { origin: "workflow" }).catch(() => false);
230346
+ const ok = await this.agentOps.sendUserMessage(run2.source_session_id, buildFeedbackMessage(feedback), project?.path ?? void 0, void 0, FEEDBACK_TURN).catch(() => false);
229413
230347
  if (!ok) {
229414
230348
  await this.storage.workflowRuns.transition(runId, "sending_feedback", "waiting_feedback", {
229415
230349
  error: "\u53D1\u9001\u5931\u8D25\uFF1A\u76EE\u6807 session \u53EF\u80FD\u672A\u8FD0\u884C\u3002\u8BF7\u5728\u5176\u7A97\u53E3\u4E2D\u5524\u9192\u540E\u91CD\u8BD5\uFF0C\u6216\u7ED3\u675F\u672C\u6B21 review\u3002"
@@ -229430,7 +230364,7 @@ var WorkflowEngine = class {
229430
230364
  async cancelRun(runId, reason) {
229431
230365
  const run2 = await this.storage.workflowRuns.getById(runId);
229432
230366
  if (!run2) return void 0;
229433
- if (["completed", "cancelled", "failed"].includes(run2.status)) return run2;
230367
+ if (TERMINAL_STATUSES.has(run2.status)) return run2;
229434
230368
  const patch = reason ? { error: reason } : void 0;
229435
230369
  const cancelled = await this.storage.workflowRuns.transition(runId, "waiting_reviewer", "cancelled", patch) || await this.storage.workflowRuns.transition(runId, "waiting_feedback", "cancelled", patch);
229436
230370
  if (!cancelled) {
@@ -231244,6 +232178,154 @@ var SchedulerService = class {
231244
232178
  }
231245
232179
  };
231246
232180
 
232181
+ // src/notification-service.ts
232182
+ var TITLE_BY_KIND = {
232183
+ review_ready: "Review feedback is ready",
232184
+ session_result_ready: "Session result is ready",
232185
+ session_failed: "Session failed",
232186
+ workflow_failed: "Workflow needs attention"
232187
+ };
232188
+ var PLACEHOLDER_TITLES = /* @__PURE__ */ new Set(["New Session", "Generating title\u2026", "Generating title..."]);
232189
+ var LOCAL_CURSOR_KEY = "notification_local_cursor";
232190
+ var OUTBOX_PAGE_SIZE = 100;
232191
+ var READ_HISTORY_PER_USER = 500;
232192
+ var OUTBOX_RETENTION_DAYS = 90;
232193
+ var DRAIN_INTERVAL_MS = 3e4;
232194
+ var CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1e3;
232195
+ function notificationBody(opts) {
232196
+ const title = opts.sessionTitle?.trim();
232197
+ if (title && !PLACEHOLDER_TITLES.has(title)) return title;
232198
+ if (opts.branch) return opts.branch;
232199
+ return opts.projectName?.trim() || null;
232200
+ }
232201
+ function notificationTitle(kind) {
232202
+ return TITLE_BY_KIND[kind] ?? "Update";
232203
+ }
232204
+ var NotificationService = class {
232205
+ constructor(storage, eventBus) {
232206
+ this.storage = storage;
232207
+ this.eventBus = eventBus;
232208
+ }
232209
+ storage;
232210
+ eventBus;
232211
+ drainTimer = null;
232212
+ cleanupTimer = null;
232213
+ /** Serializes drains so a nudge can't interleave with the periodic tick. */
232214
+ drainChain = Promise.resolve();
232215
+ stopped = false;
232216
+ /**
232217
+ * Start the background timers. Unref'd: a pending drain must never be the
232218
+ * reason a CLI process refuses to exit.
232219
+ */
232220
+ start() {
232221
+ if (this.drainTimer || this.stopped) return;
232222
+ this.drainTimer = setInterval(() => this.requestDrain(), DRAIN_INTERVAL_MS);
232223
+ this.drainTimer.unref?.();
232224
+ this.cleanupTimer = setInterval(() => {
232225
+ void this.cleanup().catch((err) => console.warn("[Notifications] cleanup failed:", err));
232226
+ }, CLEANUP_INTERVAL_MS);
232227
+ this.cleanupTimer.unref?.();
232228
+ }
232229
+ shutdown() {
232230
+ this.stopped = true;
232231
+ if (this.drainTimer) clearInterval(this.drainTimer);
232232
+ if (this.cleanupTimer) clearInterval(this.cleanupTimer);
232233
+ this.drainTimer = null;
232234
+ this.cleanupTimer = null;
232235
+ }
232236
+ /**
232237
+ * Fire-and-forget nudge used right after a milestone lands, so the bell is
232238
+ * fast in the common case. Queued behind any in-flight drain.
232239
+ */
232240
+ requestDrain() {
232241
+ if (this.stopped) return;
232242
+ this.drainChain = this.drainChain.then(() => this.drainLocal()).catch((err) => console.warn("[Notifications] local drain failed:", err));
232243
+ }
232244
+ /**
232245
+ * Import every local outbox event after the durable cursor.
232246
+ *
232247
+ * The cursor advances per page AFTER its rows are inserted, so a crash
232248
+ * mid-drain replays the page — harmless, because insertion is idempotent on
232249
+ * the milestone id and `notification:created` is only emitted for rows this
232250
+ * call actually inserted.
232251
+ */
232252
+ async drainLocal() {
232253
+ for (; ; ) {
232254
+ const cursor = await this.readLocalCursor();
232255
+ const events = await this.storage.notificationOutbox.listAfter(cursor, OUTBOX_PAGE_SIZE);
232256
+ if (events.length === 0) return;
232257
+ for (const event of events) {
232258
+ await this.importLocalEvent(event);
232259
+ }
232260
+ await this.storage.settings.set(LOCAL_CURSOR_KEY, String(events[events.length - 1].seq));
232261
+ if (events.length < OUTBOX_PAGE_SIZE) return;
232262
+ }
232263
+ }
232264
+ async importLocalEvent(event) {
232265
+ const ownerId = await this.storage.projects.getOwnerId(event.project_id);
232266
+ if (ownerId === void 0) {
232267
+ console.warn(`[Notifications] dropping outbox event ${event.id}: project ${event.project_id} not found`);
232268
+ return;
232269
+ }
232270
+ const notification = await this.buildNotification(event, {
232271
+ id: event.id,
232272
+ // "" is the solo-mode column default; the browser routes read the
232273
+ // "local" sentinel, so normalize here rather than in every query.
232274
+ userId: ownerId || "local",
232275
+ sessionId: event.session_id,
232276
+ projectId: event.project_id,
232277
+ workflowRunId: event.workflow_run_id
232278
+ });
232279
+ if (await this.storage.notifications.insert(notification)) {
232280
+ this.eventBus.emit({ type: "notification:created", projectId: notification.project_id, notification });
232281
+ }
232282
+ }
232283
+ /**
232284
+ * Assemble the persisted row. `ids` is separate from the event because remote
232285
+ * imports substitute front-local identities (see RemoteNotificationSync) while
232286
+ * reusing this exact copy generation.
232287
+ */
232288
+ async buildNotification(event, ids) {
232289
+ const session = ids.sessionId ? await this.storage.agentSessions.getById(ids.sessionId) : void 0;
232290
+ const project = await this.storage.projects.getById(ids.projectId);
232291
+ return {
232292
+ id: ids.id,
232293
+ user_id: ids.userId,
232294
+ kind: event.kind,
232295
+ project_id: ids.projectId,
232296
+ branch: event.branch,
232297
+ session_id: ids.sessionId,
232298
+ workflow_run_id: ids.workflowRunId,
232299
+ title: notificationTitle(event.kind),
232300
+ body: notificationBody({
232301
+ sessionTitle: session?.title,
232302
+ branch: event.branch,
232303
+ projectName: project?.name
232304
+ }),
232305
+ created_at: event.created_at,
232306
+ read_at: null
232307
+ };
232308
+ }
232309
+ /** Emit for a row a remote import just inserted. */
232310
+ emitCreated(notification) {
232311
+ this.eventBus.emit({ type: "notification:created", projectId: notification.project_id, notification });
232312
+ }
232313
+ /** Retention: cap read history per user, prune aged worker outbox rows. */
232314
+ async cleanup() {
232315
+ await this.storage.notifications.cleanup(READ_HISTORY_PER_USER);
232316
+ await this.storage.notificationOutbox.pruneOlderThan(Date.now() - OUTBOX_RETENTION_DAYS * 864e5);
232317
+ }
232318
+ async readLocalCursor() {
232319
+ const raw = await this.storage.settings.get(LOCAL_CURSOR_KEY);
232320
+ const parsed = raw === void 0 ? 0 : Number(raw);
232321
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
232322
+ }
232323
+ /** Simulates a crash between inbox insertion and cursor commit. */
232324
+ async resetLocalCursorForTest() {
232325
+ await this.storage.settings.set(LOCAL_CURSOR_KEY, "0");
232326
+ }
232327
+ };
232328
+
231247
232329
  // src/plugins/shared-services.ts
231248
232330
  var sharedServices = async (fastify2, opts) => {
231249
232331
  const processManager = new ProcessManager(opts.storage);
@@ -231382,8 +232464,25 @@ var sharedServices = async (fastify2, opts) => {
231382
232464
  fastify2.decorate("browserManager", browserManager);
231383
232465
  fastify2.decorate("scheduler", scheduler);
231384
232466
  agentSessionManager.setEventBus(eventBus);
232467
+ const notificationService = new NotificationService(opts.storage, eventBus);
232468
+ fastify2.decorate("notificationService", notificationService);
232469
+ agentSessionManager.setMilestoneListener(() => notificationService.requestDrain());
232470
+ const remoteNotificationSync = new RemoteNotificationSync({
232471
+ storage: opts.storage,
232472
+ notificationService,
232473
+ reverseConnectManager
232474
+ });
232475
+ fastify2.decorate("remoteNotificationSync", remoteNotificationSync);
232476
+ remoteNotificationSync.setEventBus(eventBus);
232477
+ reverseConnectManager.setStatusChangeHandler((remoteServerId, status) => {
232478
+ if (status !== "online") return;
232479
+ remoteNotificationSync.enqueue(
232480
+ () => remoteNotificationSync.syncServer(remoteServerId, { includeExpired: true })
232481
+ );
232482
+ });
231385
232483
  const workflowEngine = new WorkflowEngine(opts.storage, agentSessionManager);
231386
232484
  workflowEngine.setEventBus(eventBus);
232485
+ workflowEngine.setMilestoneListener(() => notificationService.requestDrain());
231387
232486
  await workflowEngine.init();
231388
232487
  fastify2.decorate("workflowEngine", workflowEngine);
231389
232488
  chatSessionManager.setWorkflowEngine(workflowEngine);
@@ -231393,6 +232492,10 @@ var sharedServices = async (fastify2, opts) => {
231393
232492
  processManager.setEventBus(eventBus);
231394
232493
  scheduler.setEventBus(eventBus);
231395
232494
  await scheduler.start();
232495
+ notificationService.start();
232496
+ notificationService.requestDrain();
232497
+ remoteNotificationSync.start();
232498
+ remoteNotificationSync.enqueue(() => remoteNotificationSync.syncAll({ includeExpired: true }));
231396
232499
  void (async () => {
231397
232500
  const savedRows = await opts.storage.remoteExecutorProcesses.getRunning();
231398
232501
  const directUrlRows = savedRows.filter((r) => r.remote_url);
@@ -231408,6 +232511,8 @@ var sharedServices = async (fastify2, opts) => {
231408
232511
  });
231409
232512
  fastify2.addHook("onClose", async () => {
231410
232513
  scheduler.shutdown();
232514
+ notificationService.shutdown();
232515
+ remoteNotificationSync.shutdown();
231411
232516
  agentSessionManager.shutdown();
231412
232517
  processManager.shutdown();
231413
232518
  remotePatchCache.shutdown();
@@ -231416,10 +232521,10 @@ var sharedServices = async (fastify2, opts) => {
231416
232521
  await browserManager.shutdown();
231417
232522
  });
231418
232523
  };
231419
- var shared_services_default = (0, import_fastify_plugin.default)(sharedServices, { name: "shared-services" });
232524
+ var shared_services_default = (0, import_fastify_plugin2.default)(sharedServices, { name: "shared-services" });
231420
232525
 
231421
232526
  // src/routes/project-routes.ts
231422
- var import_fastify_plugin2 = __toESM(require_plugin2(), 1);
232527
+ var import_fastify_plugin3 = __toESM(require_plugin2(), 1);
231423
232528
  import path8 from "path";
231424
232529
  import { randomUUID as randomUUID9 } from "crypto";
231425
232530
  import { exec as exec2 } from "child_process";
@@ -231472,7 +232577,7 @@ function sanitizeProject(project) {
231472
232577
  const { remote_api_key, ...safe } = project;
231473
232578
  return { ...safe, has_remote_api_key: !!remote_api_key };
231474
232579
  }
231475
- var routes = async (fastify2) => {
232580
+ var routes2 = async (fastify2) => {
231476
232581
  fastify2.get("/api/projects", async (req, reply) => {
231477
232582
  const userId = requireAuth(req, reply);
231478
232583
  if (userId === null) return;
@@ -231734,12 +232839,12 @@ var routes = async (fastify2) => {
231734
232839
  }
231735
232840
  });
231736
232841
  };
231737
- var project_routes_default = (0, import_fastify_plugin2.default)(routes, { name: "project-routes" });
232842
+ var project_routes_default = (0, import_fastify_plugin3.default)(routes2, { name: "project-routes" });
231738
232843
 
231739
232844
  // src/routes/remote-routes.ts
231740
- var import_fastify_plugin3 = __toESM(require_plugin2(), 1);
232845
+ var import_fastify_plugin4 = __toESM(require_plugin2(), 1);
231741
232846
  import { randomUUID as randomUUID10 } from "crypto";
231742
- var routes2 = async (fastify2) => {
232847
+ var routes3 = async (fastify2) => {
231743
232848
  fastify2.post("/api/remote/test-connection", async (req, reply) => {
231744
232849
  const { url: url2, apiKey } = req.body;
231745
232850
  if (!url2 || !apiKey) {
@@ -231791,17 +232896,17 @@ var routes2 = async (fastify2) => {
231791
232896
  return reply.code(201).send({ project: safeProject });
231792
232897
  });
231793
232898
  };
231794
- var remote_routes_default = (0, import_fastify_plugin3.default)(routes2, { name: "remote-routes" });
232899
+ var remote_routes_default = (0, import_fastify_plugin4.default)(routes3, { name: "remote-routes" });
231795
232900
 
231796
232901
  // src/routes/remote-server-routes.ts
231797
- var import_fastify_plugin4 = __toESM(require_plugin2(), 1);
232902
+ var import_fastify_plugin5 = __toESM(require_plugin2(), 1);
231798
232903
  function sanitizeServer(server) {
231799
232904
  const { api_key: _3, connect_token: _t, ...safe } = server;
231800
232905
  return safe;
231801
232906
  }
231802
232907
  var CROSS_REMOTE_ACCESS_VALUES = ["off", "read", "exec"];
231803
232908
  var isCrossRemoteAccess = (value) => typeof value === "string" && CROSS_REMOTE_ACCESS_VALUES.includes(value);
231804
- var routes3 = async (fastify2) => {
232909
+ var routes4 = async (fastify2) => {
231805
232910
  fastify2.get("/api/remote-servers", async (request, reply) => {
231806
232911
  const userId = requireAuth(request, reply);
231807
232912
  if (userId === null) return;
@@ -231986,15 +233091,15 @@ var routes3 = async (fastify2) => {
231986
233091
  }
231987
233092
  );
231988
233093
  };
231989
- var remote_server_routes_default = (0, import_fastify_plugin4.default)(routes3, { name: "remote-server-routes" });
233094
+ var remote_server_routes_default = (0, import_fastify_plugin5.default)(routes4, { name: "remote-server-routes" });
231990
233095
 
231991
233096
  // src/routes/project-remote-routes.ts
231992
- var import_fastify_plugin5 = __toESM(require_plugin2(), 1);
233097
+ var import_fastify_plugin6 = __toESM(require_plugin2(), 1);
231993
233098
  function sanitizeProjectRemote(pr) {
231994
233099
  const { server_api_key: _3, ...safe } = pr;
231995
233100
  return safe;
231996
233101
  }
231997
- var routes4 = async (fastify2) => {
233102
+ var routes5 = async (fastify2) => {
231998
233103
  fastify2.get(
231999
233104
  "/api/projects/:id/remotes",
232000
233105
  async (request, reply) => {
@@ -232088,12 +233193,12 @@ var routes4 = async (fastify2) => {
232088
233193
  }
232089
233194
  );
232090
233195
  };
232091
- var project_remote_routes_default = (0, import_fastify_plugin5.default)(routes4, { name: "project-remote-routes" });
233196
+ var project_remote_routes_default = (0, import_fastify_plugin6.default)(routes5, { name: "project-remote-routes" });
232092
233197
 
232093
233198
  // src/routes/executor-group-routes.ts
232094
- var import_fastify_plugin6 = __toESM(require_plugin2(), 1);
233199
+ var import_fastify_plugin7 = __toESM(require_plugin2(), 1);
232095
233200
  import { randomUUID as randomUUID11 } from "crypto";
232096
- var routes5 = async (fastify2) => {
233201
+ var routes6 = async (fastify2) => {
232097
233202
  fastify2.get(
232098
233203
  "/api/projects/:projectId/executor-groups",
232099
233204
  async (req, reply) => {
@@ -232164,17 +233269,17 @@ var routes5 = async (fastify2) => {
232164
233269
  }
232165
233270
  );
232166
233271
  };
232167
- var executor_group_routes_default = (0, import_fastify_plugin6.default)(routes5, { name: "executor-group-routes" });
233272
+ var executor_group_routes_default = (0, import_fastify_plugin7.default)(routes6, { name: "executor-group-routes" });
232168
233273
 
232169
233274
  // src/routes/executor-routes.ts
232170
- var import_fastify_plugin7 = __toESM(require_plugin2(), 1);
233275
+ var import_fastify_plugin8 = __toESM(require_plugin2(), 1);
232171
233276
  import { randomUUID as randomUUID12 } from "crypto";
232172
233277
  function normalizeSqlTimestamp(value) {
232173
233278
  if (!value) return null;
232174
233279
  if (value.includes("T") && /(Z|[+-]\d{2}:?\d{2})$/.test(value)) return value;
232175
233280
  return value.replace(" ", "T") + "Z";
232176
233281
  }
232177
- var routes6 = async (fastify2) => {
233282
+ var routes7 = async (fastify2) => {
232178
233283
  fastify2.get(
232179
233284
  "/api/projects/:projectId/executors",
232180
233285
  async (req, reply) => {
@@ -232313,13 +233418,13 @@ var routes6 = async (fastify2) => {
232313
233418
  return reply.code(200).send({ success: true });
232314
233419
  });
232315
233420
  };
232316
- var executor_routes_default = (0, import_fastify_plugin7.default)(routes6, { name: "executor-routes" });
233421
+ var executor_routes_default = (0, import_fastify_plugin8.default)(routes7, { name: "executor-routes" });
232317
233422
 
232318
233423
  // src/routes/process-routes.ts
232319
- var import_fastify_plugin8 = __toESM(require_plugin2(), 1);
233424
+ var import_fastify_plugin9 = __toESM(require_plugin2(), 1);
232320
233425
  import path9 from "path";
232321
233426
  import { randomUUID as randomUUID13 } from "crypto";
232322
- var routes7 = async (fastify2) => {
233427
+ var routes8 = async (fastify2) => {
232323
233428
  fastify2.post("/api/path/execute", async (req, reply) => {
232324
233429
  const { path: projectPath, command, executor_type, prompt_provider, cwd, branch, pty: pty2 } = req.body;
232325
233430
  if (!projectPath || !command) {
@@ -232529,10 +233634,10 @@ var routes7 = async (fastify2) => {
232529
233634
  return reply.code(200).send({ processes });
232530
233635
  });
232531
233636
  };
232532
- var process_routes_default = (0, import_fastify_plugin8.default)(routes7, { name: "process-routes" });
233637
+ var process_routes_default = (0, import_fastify_plugin9.default)(routes8, { name: "process-routes" });
232533
233638
 
232534
233639
  // src/routes/worktree-routes.ts
232535
- var import_fastify_plugin9 = __toESM(require_plugin2(), 1);
233640
+ var import_fastify_plugin10 = __toESM(require_plugin2(), 1);
232536
233641
  import { mkdir as mkdir3 } from "fs/promises";
232537
233642
  async function getAllRemoteConfigs(fastify2, project) {
232538
233643
  const remotes = await fastify2.storage.projectRemotes.getByProject(project.id);
@@ -232558,7 +233663,7 @@ async function getRemoteConfig(fastify2, project) {
232558
233663
  const all = await getAllRemoteConfigs(fastify2, project);
232559
233664
  return all.length > 0 ? all[0] : null;
232560
233665
  }
232561
- var routes8 = async (fastify2) => {
233666
+ var routes9 = async (fastify2) => {
232562
233667
  fastify2.get("/api/path/branches", async (req, reply) => {
232563
233668
  const projectPath = req.query.path;
232564
233669
  if (!projectPath) {
@@ -233096,10 +234201,10 @@ var routes8 = async (fastify2) => {
233096
234201
  });
233097
234202
  });
233098
234203
  };
233099
- var worktree_routes_default = (0, import_fastify_plugin9.default)(routes8, { name: "worktree-routes" });
234204
+ var worktree_routes_default = (0, import_fastify_plugin10.default)(routes9, { name: "worktree-routes" });
233100
234205
 
233101
234206
  // src/routes/diff-routes.ts
233102
- var import_fastify_plugin10 = __toESM(require_plugin2(), 1);
234207
+ var import_fastify_plugin11 = __toESM(require_plugin2(), 1);
233103
234208
  import path10 from "path";
233104
234209
  import { readFileSync as readFileSync4 } from "fs";
233105
234210
  import { execFileSync as execFileSync5 } from "child_process";
@@ -233419,7 +234524,7 @@ async function getUntrackedFiles(cwd) {
233419
234524
  }
233420
234525
  return files;
233421
234526
  }
233422
- var routes9 = async (fastify2) => {
234527
+ var routes10 = async (fastify2) => {
233423
234528
  fastify2.get("/api/path/diff", async (req, reply) => {
233424
234529
  const projectPath = req.query.path;
233425
234530
  if (!projectPath) {
@@ -233614,10 +234719,10 @@ var routes9 = async (fastify2) => {
233614
234719
  }
233615
234720
  });
233616
234721
  };
233617
- var diff_routes_default = (0, import_fastify_plugin10.default)(routes9, { name: "diff-routes" });
234722
+ var diff_routes_default = (0, import_fastify_plugin11.default)(routes10, { name: "diff-routes" });
233618
234723
 
233619
234724
  // src/routes/file-routes.ts
233620
- var import_fastify_plugin11 = __toESM(require_plugin2(), 1);
234725
+ var import_fastify_plugin12 = __toESM(require_plugin2(), 1);
233621
234726
  import path11 from "path";
233622
234727
  import fs2 from "fs/promises";
233623
234728
  import { createReadStream, constants as fsConstants } from "fs";
@@ -233853,7 +234958,7 @@ async function searchSymbol(basePath33, symbol26) {
233853
234958
  }
233854
234959
  return { hits, truncated };
233855
234960
  }
233856
- var routes10 = async (fastify2) => {
234961
+ var routes11 = async (fastify2) => {
233857
234962
  fastify2.get("/api/path/browse", async (req, reply) => {
233858
234963
  const projectPath = req.query.path;
233859
234964
  if (!projectPath) {
@@ -234447,10 +235552,10 @@ var routes10 = async (fastify2) => {
234447
235552
  }
234448
235553
  });
234449
235554
  };
234450
- var file_routes_default = (0, import_fastify_plugin11.default)(routes10, { name: "file-routes" });
235555
+ var file_routes_default = (0, import_fastify_plugin12.default)(routes11, { name: "file-routes" });
234451
235556
 
234452
235557
  // src/routes/agent-session-routes.ts
234453
- var import_fastify_plugin12 = __toESM(require_plugin2(), 1);
235558
+ var import_fastify_plugin13 = __toESM(require_plugin2(), 1);
234454
235559
 
234455
235560
  // src/utils/paste-file.ts
234456
235561
  import { chmod, mkdir as mkdir4, open } from "node:fs/promises";
@@ -234498,7 +235603,7 @@ function messageTextLength(content) {
234498
235603
  }
234499
235604
  return n;
234500
235605
  }
234501
- var routes11 = async (fastify2) => {
235606
+ var routes12 = async (fastify2) => {
234502
235607
  function proxyAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body) {
234503
235608
  return proxyToRemoteAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body, {
234504
235609
  reverseConnectManager: fastify2.reverseConnectManager
@@ -234756,7 +235861,8 @@ var routes11 = async (fastify2) => {
234756
235861
  project.id,
234757
235862
  project.agent_mode,
234758
235863
  s3.id,
234759
- s3.branch ?? null
235864
+ s3.branch ?? null,
235865
+ "from_now"
234760
235866
  );
234761
235867
  return { ...s3, id: localSessionId, processAlive: s3.processAlive ?? false, entry_count: s3.entry_count ?? 0 };
234762
235868
  }));
@@ -234851,7 +235957,8 @@ var routes11 = async (fastify2) => {
234851
235957
  project.id,
234852
235958
  agentMode,
234853
235959
  remoteData.session.id,
234854
- branch ?? null
235960
+ branch ?? null,
235961
+ "from_now"
234855
235962
  );
234856
235963
  if (remoteData.messages && remoteData.messages.length > 0) {
234857
235964
  const cacheEntry = fastify2.remotePatchCache.getOrCreate(localSessionId);
@@ -235078,6 +236185,12 @@ var routes11 = async (fastify2) => {
235078
236185
  console.log(`[API] /message 404: remote session not found. Known keys: [${[...fastify2.remoteSessionMap.keys()].join(", ")}]`);
235079
236186
  return reply.code(404).send({ error: "Remote session not found" });
235080
236187
  }
236188
+ if (!await fastify2.remoteNotificationSync.prepareForNewTurn(req.params.sessionId)) {
236189
+ return reply.code(502).send({
236190
+ error: "Could not reach the remote server to prepare notification delivery",
236191
+ errorCode: "notification_baseline_failed"
236192
+ });
236193
+ }
235081
236194
  const result = await proxyAuto(
235082
236195
  remoteInfo.remoteServerId,
235083
236196
  remoteInfo.remoteUrl,
@@ -235332,7 +236445,8 @@ var routes11 = async (fastify2) => {
235332
236445
  projectId,
235333
236446
  remoteInfo.remoteServerId,
235334
236447
  newRemoteSessionId,
235335
- remoteInfo.branch ?? null
236448
+ remoteInfo.branch ?? null,
236449
+ "from_start"
235336
236450
  );
235337
236451
  await fastify2.storage.remoteSessionMappings.markTitleResolved(localSessionId);
235338
236452
  fastify2.agentSessionManager.markTitleResolved(localSessionId);
@@ -235627,10 +236741,10 @@ var routes11 = async (fastify2) => {
235627
236741
  return reply.code(200).send({ success: true, favorited });
235628
236742
  });
235629
236743
  };
235630
- var agent_session_routes_default = (0, import_fastify_plugin12.default)(routes11, { name: "agent-session-routes" });
236744
+ var agent_session_routes_default = (0, import_fastify_plugin13.default)(routes12, { name: "agent-session-routes" });
235631
236745
 
235632
236746
  // src/routes/branch-activity-routes.ts
235633
- var import_fastify_plugin13 = __toESM(require_plugin2(), 1);
236747
+ var import_fastify_plugin14 = __toESM(require_plugin2(), 1);
235634
236748
  function toResponse(map2) {
235635
236749
  return {
235636
236750
  branches: Array.from(map2.entries()).map(([branch, state]) => ({
@@ -235645,7 +236759,7 @@ function fromResponse(resp) {
235645
236759
  resp.branches.map((b2) => [b2.branch ?? "", { activity: b2.activity, since: b2.since }])
235646
236760
  );
235647
236761
  }
235648
- var routes12 = async (fastify2) => {
236762
+ var routes13 = async (fastify2) => {
235649
236763
  function proxyAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body) {
235650
236764
  return proxyToRemoteAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body, {
235651
236765
  reverseConnectManager: fastify2.reverseConnectManager
@@ -235712,10 +236826,10 @@ var routes12 = async (fastify2) => {
235712
236826
  }
235713
236827
  );
235714
236828
  };
235715
- var branch_activity_routes_default = (0, import_fastify_plugin13.default)(routes12, { name: "branch-activity-routes" });
236829
+ var branch_activity_routes_default = (0, import_fastify_plugin14.default)(routes13, { name: "branch-activity-routes" });
235716
236830
 
235717
236831
  // src/routes/merge-status-routes.ts
235718
- var import_fastify_plugin14 = __toESM(require_plugin2(), 1);
236832
+ var import_fastify_plugin15 = __toESM(require_plugin2(), 1);
235719
236833
  function resolveTargets(comparisons, storedTargets) {
235720
236834
  const sources = [];
235721
236835
  const effective = comparisons.map((comparison) => {
@@ -235849,7 +236963,7 @@ function sendComputed(reply, repoPath, comparisons, repository) {
235849
236963
  return reply.code(statusCode).send({ error: message });
235850
236964
  }
235851
236965
  }
235852
- var routes13 = async (fastify2) => {
236966
+ var routes14 = async (fastify2) => {
235853
236967
  fastify2.post(
235854
236968
  "/api/path/branches/merge-status",
235855
236969
  async (req, reply) => {
@@ -235973,11 +237087,11 @@ var routes13 = async (fastify2) => {
235973
237087
  }
235974
237088
  );
235975
237089
  };
235976
- var merge_status_routes_default = (0, import_fastify_plugin14.default)(routes13, { name: "merge-status-routes" });
237090
+ var merge_status_routes_default = (0, import_fastify_plugin15.default)(routes14, { name: "merge-status-routes" });
235977
237091
 
235978
237092
  // src/routes/chat-session-routes.ts
235979
- var import_fastify_plugin15 = __toESM(require_plugin2(), 1);
235980
- var routes14 = async (fastify2) => {
237093
+ var import_fastify_plugin16 = __toESM(require_plugin2(), 1);
237094
+ var routes15 = async (fastify2) => {
235981
237095
  const getAuthorizedSession = (req, reply, sessionId) => {
235982
237096
  const authResult = requireAuth(req, reply);
235983
237097
  if (authResult === null) return null;
@@ -236082,12 +237196,12 @@ var routes14 = async (fastify2) => {
236082
237196
  return reply.send({ ok: true });
236083
237197
  });
236084
237198
  };
236085
- var chat_session_routes_default = (0, import_fastify_plugin15.default)(routes14, { name: "chat-session-routes" });
237199
+ var chat_session_routes_default = (0, import_fastify_plugin16.default)(routes15, { name: "chat-session-routes" });
236086
237200
 
236087
237201
  // src/routes/task-routes.ts
236088
- var import_fastify_plugin16 = __toESM(require_plugin2(), 1);
237202
+ var import_fastify_plugin17 = __toESM(require_plugin2(), 1);
236089
237203
  import { randomUUID as randomUUID16 } from "crypto";
236090
- var routes15 = async (fastify2) => {
237204
+ var routes16 = async (fastify2) => {
236091
237205
  fastify2.get(
236092
237206
  "/api/projects/:projectId/tasks",
236093
237207
  async (req, reply) => {
@@ -236233,12 +237347,12 @@ Description: ${description}`,
236233
237347
  return reply.code(200).send({ success: true });
236234
237348
  });
236235
237349
  };
236236
- var task_routes_default = (0, import_fastify_plugin16.default)(routes15, { name: "task-routes" });
237350
+ var task_routes_default = (0, import_fastify_plugin17.default)(routes16, { name: "task-routes" });
236237
237351
 
236238
237352
  // src/routes/rule-routes.ts
236239
- var import_fastify_plugin17 = __toESM(require_plugin2(), 1);
237353
+ var import_fastify_plugin18 = __toESM(require_plugin2(), 1);
236240
237354
  import { randomUUID as randomUUID17 } from "crypto";
236241
- var routes16 = async (fastify2) => {
237355
+ var routes17 = async (fastify2) => {
236242
237356
  fastify2.get(
236243
237357
  "/api/projects/:projectId/rules",
236244
237358
  async (req, reply) => {
@@ -236324,12 +237438,12 @@ var routes16 = async (fastify2) => {
236324
237438
  return reply.code(200).send({ success: true });
236325
237439
  });
236326
237440
  };
236327
- var rule_routes_default = (0, import_fastify_plugin17.default)(routes16, { name: "rule-routes" });
237441
+ var rule_routes_default = (0, import_fastify_plugin18.default)(routes17, { name: "rule-routes" });
236328
237442
 
236329
237443
  // src/routes/command-routes.ts
236330
- var import_fastify_plugin18 = __toESM(require_plugin2(), 1);
237444
+ var import_fastify_plugin19 = __toESM(require_plugin2(), 1);
236331
237445
  import { randomUUID as randomUUID18 } from "crypto";
236332
- var routes17 = async (fastify2) => {
237446
+ var routes18 = async (fastify2) => {
236333
237447
  fastify2.get(
236334
237448
  "/api/projects/:projectId/commands",
236335
237449
  async (req, reply) => {
@@ -236398,10 +237512,10 @@ var routes17 = async (fastify2) => {
236398
237512
  return reply.code(200).send({ success: true });
236399
237513
  });
236400
237514
  };
236401
- var command_routes_default = (0, import_fastify_plugin18.default)(routes17, { name: "command-routes" });
237515
+ var command_routes_default = (0, import_fastify_plugin19.default)(routes18, { name: "command-routes" });
236402
237516
 
236403
237517
  // src/routes/workflow-run-routes.ts
236404
- var import_fastify_plugin19 = __toESM(require_plugin2(), 1);
237518
+ var import_fastify_plugin20 = __toESM(require_plugin2(), 1);
236405
237519
 
236406
237520
  // src/utils/review-brief.ts
236407
237521
  var AI_TIMEOUT_MS2 = 15e3;
@@ -236515,7 +237629,7 @@ function errStatus(err) {
236515
237629
  return 500;
236516
237630
  }
236517
237631
  }
236518
- async function routes18(fastify2) {
237632
+ async function routes19(fastify2) {
236519
237633
  const remoteRunMap = /* @__PURE__ */ new Map();
236520
237634
  const TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "cancelled", "failed"]);
236521
237635
  const trackRemoteRun = (localRun, info) => {
@@ -236599,6 +237713,12 @@ async function routes18(fastify2) {
236599
237713
  if (!clientProvidedBrief && !bareReviewerSessionId) {
236600
237714
  intentBrief2 = await distillIntentBrief(userId, sourceSessionId);
236601
237715
  }
237716
+ if (reviewerSessionId && !await fastify2.remoteNotificationSync.prepareForNewTurn(reviewerSessionId)) {
237717
+ return reply.code(502).send({
237718
+ error: "Could not reach the remote server to prepare notification delivery",
237719
+ errorCode: "notification_baseline_failed"
237720
+ });
237721
+ }
236602
237722
  const result = await proxyAuto(remoteInfo, "POST", "/api/path/workflow-runs", {
236603
237723
  sourceSessionId: remoteInfo.remoteSessionId,
236604
237724
  reviewFocus,
@@ -236632,7 +237752,12 @@ async function routes18(fastify2) {
236632
237752
  projectId,
236633
237753
  remoteInfo.remoteServerId,
236634
237754
  bareRun.reviewer_session_id,
236635
- bareRun.branch
237755
+ bareRun.branch,
237756
+ "from_start"
237757
+ );
237758
+ await fastify2.remoteNotificationSync.extendWatch(localRun.reviewer_session_id);
237759
+ fastify2.remoteNotificationSync.enqueue(
237760
+ () => fastify2.remoteNotificationSync.syncServer(remoteInfo.remoteServerId, { includeExpired: true })
236636
237761
  );
236637
237762
  ensureRemoteAgentStream(localRun.reviewer_session_id, {
236638
237763
  remoteSessionMap: fastify2.remoteSessionMap,
@@ -236641,6 +237766,11 @@ async function routes18(fastify2) {
236641
237766
  eventBus: fastify2.eventBus,
236642
237767
  agentSessionManager: fastify2.agentSessionManager
236643
237768
  });
237769
+ fastify2.agentSessionManager.emitBranchActivityIfChanged(projectId, bareRun.branch, {
237770
+ activity: "working",
237771
+ since: Date.now(),
237772
+ sessionId: localRun.reviewer_session_id
237773
+ });
236644
237774
  fastify2.eventBus.emit({
236645
237775
  type: "session:process",
236646
237776
  projectId,
@@ -236755,7 +237885,8 @@ async function routes18(fastify2) {
236755
237885
  projectId,
236756
237886
  remoteInfo.remoteServerId,
236757
237887
  bareCandidate.sessionId,
236758
- remoteInfo.branch ?? null
237888
+ remoteInfo.branch ?? null,
237889
+ "from_now"
236759
237890
  );
236760
237891
  }
236761
237892
  return reply.send({ candidate: candidate2 });
@@ -236949,10 +238080,10 @@ async function routes18(fastify2) {
236949
238080
  return reply.send({ runs });
236950
238081
  });
236951
238082
  }
236952
- var workflow_run_routes_default = (0, import_fastify_plugin19.default)(routes18, { name: "workflow-run-routes" });
238083
+ var workflow_run_routes_default = (0, import_fastify_plugin20.default)(routes19, { name: "workflow-run-routes" });
236953
238084
 
236954
238085
  // src/routes/settings-routes.ts
236955
- var import_fastify_plugin20 = __toESM(require_plugin2(), 1);
238086
+ var import_fastify_plugin21 = __toESM(require_plugin2(), 1);
236956
238087
  var SettingsValidationError = class extends Error {
236957
238088
  };
236958
238089
  var DEFAULT_PROXY_CONFIG = { type: "none", host: "", port: 0 };
@@ -237016,7 +238147,7 @@ function requireSettingsUser(req, reply) {
237016
238147
  if (auth === null) return null;
237017
238148
  return resolveUserId(auth);
237018
238149
  }
237019
- var routes19 = async (fastify2) => {
238150
+ var routes20 = async (fastify2) => {
237020
238151
  fastify2.get("/api/settings/agent-processes", async (req, reply) => {
237021
238152
  if (requireAuth(req, reply) === null) return;
237022
238153
  const saved = await fastify2.storage.settings.get("agentProcesses");
@@ -237290,11 +238421,11 @@ var routes19 = async (fastify2) => {
237290
238421
  return reply.code(200).send(updated);
237291
238422
  });
237292
238423
  };
237293
- var settings_routes_default = (0, import_fastify_plugin20.default)(routes19, { name: "settings-routes" });
238424
+ var settings_routes_default = (0, import_fastify_plugin21.default)(routes20, { name: "settings-routes" });
237294
238425
 
237295
238426
  // src/routes/translate-routes.ts
237296
- var import_fastify_plugin21 = __toESM(require_plugin2(), 1);
237297
- var routes20 = async (fastify2) => {
238427
+ var import_fastify_plugin22 = __toESM(require_plugin2(), 1);
238428
+ var routes21 = async (fastify2) => {
237298
238429
  fastify2.post(
237299
238430
  "/api/translate",
237300
238431
  async (req, reply) => {
@@ -237335,10 +238466,10 @@ ${text2}`,
237335
238466
  }
237336
238467
  );
237337
238468
  };
237338
- var translate_routes_default = (0, import_fastify_plugin21.default)(routes20, { name: "translate-routes" });
238469
+ var translate_routes_default = (0, import_fastify_plugin22.default)(routes21, { name: "translate-routes" });
237339
238470
 
237340
238471
  // src/routes/websocket-routes.ts
237341
- var import_fastify_plugin22 = __toESM(require_plugin2(), 1);
238472
+ var import_fastify_plugin23 = __toESM(require_plugin2(), 1);
237342
238473
 
237343
238474
  // src/routes/executor-stream-handlers.ts
237344
238475
  import { randomUUID as randomUUID19 } from "crypto";
@@ -237616,7 +238747,7 @@ async function userOwnsSession(fastify2, sessionId, userId) {
237616
238747
  }
237617
238748
 
237618
238749
  // src/routes/websocket-routes.ts
237619
- var routes21 = async (fastify2) => {
238750
+ var routes22 = async (fastify2) => {
237620
238751
  fastify2.reverseConnectManager.setStatusChangeHandler((remoteServerId, status) => {
237621
238752
  if (status !== "online") return;
237622
238753
  const cache2 = fastify2.remotePatchCache;
@@ -238003,13 +239134,13 @@ var routes21 = async (fastify2) => {
238003
239134
  );
238004
239135
  });
238005
239136
  };
238006
- var websocket_routes_default = (0, import_fastify_plugin22.default)(routes21, { name: "websocket-routes" });
239137
+ var websocket_routes_default = (0, import_fastify_plugin23.default)(routes22, { name: "websocket-routes" });
238007
239138
 
238008
239139
  // src/routes/reverse-connect-routes.ts
238009
- var import_fastify_plugin23 = __toESM(require_plugin2(), 1);
239140
+ var import_fastify_plugin24 = __toESM(require_plugin2(), 1);
238010
239141
  import { randomBytes as randomBytes2, createHash as createHash3, verify as cryptoVerify } from "crypto";
238011
239142
  var MACHINE_HANDSHAKE_TIMEOUT_MS = 5e3;
238012
- var routes22 = async (fastify2) => {
239143
+ var routes23 = async (fastify2) => {
238013
239144
  fastify2.get("/api/reverse-connect/identity", async (req, reply) => {
238014
239145
  const token = req.headers["x-vibedeckx-connect-token"];
238015
239146
  if (typeof token !== "string" || token.length === 0) {
@@ -238119,10 +239250,10 @@ var routes22 = async (fastify2) => {
238119
239250
  );
238120
239251
  });
238121
239252
  };
238122
- var reverse_connect_routes_default = (0, import_fastify_plugin23.default)(routes22, { name: "reverse-connect-routes" });
239253
+ var reverse_connect_routes_default = (0, import_fastify_plugin24.default)(routes23, { name: "reverse-connect-routes" });
238123
239254
 
238124
239255
  // src/routes/event-routes.ts
238125
- var import_fastify_plugin24 = __toESM(require_plugin2(), 1);
239256
+ var import_fastify_plugin25 = __toESM(require_plugin2(), 1);
238126
239257
  function toWireEvent(event) {
238127
239258
  if (event.type === "executor:stopped" && (event.tailOutput !== void 0 || event.finalResult !== void 0)) {
238128
239259
  const { tailOutput: _tailOutput, finalResult: _finalResult, ...rest } = event;
@@ -238130,7 +239261,7 @@ function toWireEvent(event) {
238130
239261
  }
238131
239262
  return event;
238132
239263
  }
238133
- var routes23 = async (fastify2) => {
239264
+ var routes24 = async (fastify2) => {
238134
239265
  fastify2.get("/api/events", async (req, reply) => {
238135
239266
  let userId = null;
238136
239267
  if (fastify2.authEnabled) {
@@ -238188,10 +239319,10 @@ var routes23 = async (fastify2) => {
238188
239319
  await reply;
238189
239320
  });
238190
239321
  };
238191
- var event_routes_default = (0, import_fastify_plugin24.default)(routes23, { name: "event-routes" });
239322
+ var event_routes_default = (0, import_fastify_plugin25.default)(routes24, { name: "event-routes" });
238192
239323
 
238193
239324
  // src/routes/terminal-routes.ts
238194
- var import_fastify_plugin25 = __toESM(require_plugin2(), 1);
239325
+ var import_fastify_plugin26 = __toESM(require_plugin2(), 1);
238195
239326
  import path13 from "path";
238196
239327
  async function getRemoteConfig5(fastify2, project, remoteServerId) {
238197
239328
  const remotes = await fastify2.storage.projectRemotes.getByProject(project.id);
@@ -238216,7 +239347,7 @@ async function getRemoteConfig5(fastify2, project, remoteServerId) {
238216
239347
  }
238217
239348
  return null;
238218
239349
  }
238219
- var routes24 = async (fastify2) => {
239350
+ var routes25 = async (fastify2) => {
238220
239351
  fastify2.post("/api/path/terminals", async (req, reply) => {
238221
239352
  const { path: projectPath, branch } = req.body;
238222
239353
  if (!projectPath) {
@@ -238399,11 +239530,11 @@ var routes24 = async (fastify2) => {
238399
239530
  }
238400
239531
  );
238401
239532
  };
238402
- var terminal_routes_default = (0, import_fastify_plugin25.default)(routes24, { name: "terminal-routes" });
239533
+ var terminal_routes_default = (0, import_fastify_plugin26.default)(routes25, { name: "terminal-routes" });
238403
239534
 
238404
239535
  // src/routes/browser-routes.ts
238405
- var import_fastify_plugin26 = __toESM(require_plugin2(), 1);
238406
- var routes25 = async (fastify2) => {
239536
+ var import_fastify_plugin27 = __toESM(require_plugin2(), 1);
239537
+ var routes26 = async (fastify2) => {
238407
239538
  const ensureProjectAccess = async (req, reply) => {
238408
239539
  const userId = requireAuth(req, reply);
238409
239540
  if (userId === null) return null;
@@ -238474,10 +239605,10 @@ var routes25 = async (fastify2) => {
238474
239605
  return reply.code(200).send({ ok: true });
238475
239606
  });
238476
239607
  };
238477
- var browser_routes_default = (0, import_fastify_plugin26.default)(routes25, { name: "browser-routes" });
239608
+ var browser_routes_default = (0, import_fastify_plugin27.default)(routes26, { name: "browser-routes" });
238478
239609
 
238479
239610
  // src/routes/browser-proxy-routes.ts
238480
- var import_fastify_plugin27 = __toESM(require_plugin2(), 1);
239611
+ var import_fastify_plugin28 = __toESM(require_plugin2(), 1);
238481
239612
  import { randomUUID as randomUUID20 } from "crypto";
238482
239613
 
238483
239614
  // src/utils/ssrf-guard.ts
@@ -238973,7 +240104,7 @@ function generateInjectedScript(projectId, targetOrigin, proxyWsPrefix) {
238973
240104
  })();
238974
240105
  </script>`;
238975
240106
  }
238976
- var routes26 = async (fastify2) => {
240107
+ var routes27 = async (fastify2) => {
238977
240108
  fastify2.get("/api/projects/:id/browser/proxy/*", async (req, reply) => {
238978
240109
  const userId = requireAuth(req, reply);
238979
240110
  if (userId === null) return;
@@ -239144,10 +240275,10 @@ var routes26 = async (fastify2) => {
239144
240275
  });
239145
240276
  });
239146
240277
  };
239147
- var browser_proxy_routes_default = (0, import_fastify_plugin27.default)(routes26, { name: "browser-proxy-routes" });
240278
+ var browser_proxy_routes_default = (0, import_fastify_plugin28.default)(routes27, { name: "browser-proxy-routes" });
239148
240279
 
239149
240280
  // src/routes/cross-remote-target-routes.ts
239150
- var import_fastify_plugin28 = __toESM(require_plugin2(), 1);
240281
+ var import_fastify_plugin29 = __toESM(require_plugin2(), 1);
239151
240282
  import path14 from "path";
239152
240283
  import { promises as fs3 } from "fs";
239153
240284
 
@@ -239227,7 +240358,7 @@ var clampTimeoutMs = (timeoutSec) => {
239227
240358
  const requested = typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : DEFAULT_TIMEOUT_SEC;
239228
240359
  return Math.min(requested, MAX_TIMEOUT_SEC) * 1e3;
239229
240360
  };
239230
- var routes27 = async (fastify2) => {
240361
+ var routes28 = async (fastify2) => {
239231
240362
  fastify2.post(
239232
240363
  "/api/path/cross-remote/exec",
239233
240364
  async (request, reply) => {
@@ -239335,10 +240466,10 @@ var routes27 = async (fastify2) => {
239335
240466
  return reply.send(result);
239336
240467
  });
239337
240468
  };
239338
- var cross_remote_target_routes_default = (0, import_fastify_plugin28.default)(routes27, { name: "cross-remote-target-routes" });
240469
+ var cross_remote_target_routes_default = (0, import_fastify_plugin29.default)(routes28, { name: "cross-remote-target-routes" });
239339
240470
 
239340
240471
  // src/routes/cross-remote-mcp-routes.ts
239341
- var import_fastify_plugin29 = __toESM(require_plugin2(), 1);
240472
+ var import_fastify_plugin30 = __toESM(require_plugin2(), 1);
239342
240473
  var PROTOCOL_VERSION = "2024-11-05";
239343
240474
  var AUDIT_ARGS_MAX = 1024;
239344
240475
  var NOT_ACCESSIBLE = "remote not found or not accessible";
@@ -239441,7 +240572,7 @@ var textResult = (text2, isError = false) => ({
239441
240572
  content: [{ type: "text", text: text2 }],
239442
240573
  ...isError ? { isError: true } : {}
239443
240574
  });
239444
- var routes28 = async (fastify2) => {
240575
+ var routes29 = async (fastify2) => {
239445
240576
  const guard = new SessionConcurrencyGuard();
239446
240577
  let cachedSecret;
239447
240578
  const getSecret = () => {
@@ -239564,10 +240695,10 @@ var routes28 = async (fastify2) => {
239564
240695
  }
239565
240696
  });
239566
240697
  };
239567
- var cross_remote_mcp_routes_default = (0, import_fastify_plugin29.default)(routes28, { name: "cross-remote-mcp-routes" });
240698
+ var cross_remote_mcp_routes_default = (0, import_fastify_plugin30.default)(routes29, { name: "cross-remote-mcp-routes" });
239568
240699
 
239569
240700
  // src/routes/schedule-routes.ts
239570
- var import_fastify_plugin30 = __toESM(require_plugin2(), 1);
240701
+ var import_fastify_plugin31 = __toESM(require_plugin2(), 1);
239571
240702
  import { randomUUID as randomUUID21 } from "crypto";
239572
240703
  import path15 from "path";
239573
240704
  var RUN_TYPES = ["command", "prompt"];
@@ -239583,7 +240714,7 @@ function validateResolved(b2) {
239583
240714
  if (cronError) return `Invalid cron expression: ${cronError}`;
239584
240715
  return null;
239585
240716
  }
239586
- var routes29 = async (fastify2) => {
240717
+ var routes30 = async (fastify2) => {
239587
240718
  const getAuthorizedSchedule = async (id, userId, reply) => {
239588
240719
  const schedule = await fastify2.storage.scheduledTasks.getById(id);
239589
240720
  if (!schedule) {
@@ -239755,7 +240886,7 @@ var routes29 = async (fastify2) => {
239755
240886
  }
239756
240887
  );
239757
240888
  };
239758
- var schedule_routes_default = (0, import_fastify_plugin30.default)(routes29, { name: "schedule-routes" });
240889
+ var schedule_routes_default = (0, import_fastify_plugin31.default)(routes30, { name: "schedule-routes" });
239759
240890
 
239760
240891
  // src/search/catalog.ts
239761
240892
  var parseDbTimestamp3 = (ts) => {
@@ -239959,7 +241090,8 @@ var searchRoutes = async (fastify2) => {
239959
241090
  target.projectId,
239960
241091
  target.targetId,
239961
241092
  s3.id,
239962
- mapBranch
241093
+ mapBranch,
241094
+ "from_now"
239963
241095
  );
239964
241096
  return { ...s3, id: localSessionId };
239965
241097
  }));
@@ -239996,6 +241128,44 @@ var searchRoutes = async (fastify2) => {
239996
241128
  };
239997
241129
  var search_routes_default = searchRoutes;
239998
241130
 
241131
+ // src/routes/notification-routes.ts
241132
+ var import_fastify_plugin32 = __toESM(require_plugin2(), 1);
241133
+ var MAX_LIMIT = 500;
241134
+ var DEFAULT_LIMIT = 100;
241135
+ var routes31 = async (fastify2) => {
241136
+ fastify2.get(
241137
+ "/api/notifications",
241138
+ async (req, reply) => {
241139
+ const authResult = requireAuth(req, reply);
241140
+ if (authResult === null) return;
241141
+ const userId = resolveUserId(authResult);
241142
+ const requested = Number(req.query.limit);
241143
+ const limit = Number.isFinite(requested) && requested > 0 ? Math.min(Math.floor(requested), MAX_LIMIT) : DEFAULT_LIMIT;
241144
+ const notifications = await fastify2.storage.notifications.listForUser(userId, {
241145
+ limit,
241146
+ unreadOnly: req.query.unread === "true"
241147
+ });
241148
+ return reply.code(200).send({ notifications });
241149
+ }
241150
+ );
241151
+ fastify2.patch("/api/notifications/:id/read", async (req, reply) => {
241152
+ const authResult = requireAuth(req, reply);
241153
+ if (authResult === null) return;
241154
+ const userId = resolveUserId(authResult);
241155
+ const ok = await fastify2.storage.notifications.markRead(req.params.id, userId);
241156
+ if (!ok) return reply.code(404).send({ error: "Not found" });
241157
+ return reply.code(200).send({ ok: true });
241158
+ });
241159
+ fastify2.post("/api/notifications/read-all", async (req, reply) => {
241160
+ const authResult = requireAuth(req, reply);
241161
+ if (authResult === null) return;
241162
+ const userId = resolveUserId(authResult);
241163
+ await fastify2.storage.notifications.markAllRead(userId);
241164
+ return reply.code(200).send({ ok: true });
241165
+ });
241166
+ };
241167
+ var notification_routes_default = (0, import_fastify_plugin32.default)(routes31, { name: "notification-routes" });
241168
+
239999
241169
  // ../../node_modules/.pnpm/@clerk+fastify@3.1.2_fastify@5.7.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@clerk/fastify/dist/chunk-RY4T2JMQ.mjs
240000
241170
  import { Readable as Readable3 } from "stream";
240001
241171
  var fastifyRequestToRequest = (req) => {
@@ -240046,7 +241216,7 @@ var requestToProxyRequest = (req) => {
240046
241216
 
240047
241217
  // ../../node_modules/.pnpm/@clerk+fastify@3.1.2_fastify@5.7.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@clerk/fastify/dist/index.mjs
240048
241218
  init_dist();
240049
- var import_fastify_plugin31 = __toESM(require_plugin2(), 1);
241219
+ var import_fastify_plugin33 = __toESM(require_plugin2(), 1);
240050
241220
 
240051
241221
  // ../../node_modules/.pnpm/@clerk+backend@3.2.0_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@clerk/backend/dist/internal.mjs
240052
241222
  init_chunk_JW73PKED();
@@ -240334,7 +241504,7 @@ var plugin = (instance, opts, done) => {
240334
241504
  instance.addHook(hookName, withClerkMiddleware(opts));
240335
241505
  done();
240336
241506
  };
240337
- var clerkPlugin = (0, import_fastify_plugin31.default)(plugin, {
241507
+ var clerkPlugin = (0, import_fastify_plugin33.default)(plugin, {
240338
241508
  name: "@clerk/fastify",
240339
241509
  fastify: "5.x"
240340
241510
  });
@@ -241223,6 +242393,7 @@ var createServer = async (opts) => {
241223
242393
  authEnabled,
241224
242394
  clerkPublishableKey: authEnabled ? process.env.CLERK_PUBLISHABLE_KEY : void 0,
241225
242395
  localProjectsEnabled: !noLocalProjects,
242396
+ discordInviteUrl: process.env.VIBEDECKX_DISCORD_URL || void 0,
241226
242397
  // Capability flag for the reverse-connect identity preflight. Workers
241227
242398
  // check this before calling /api/reverse-connect/identity so an auth
241228
242399
  // middleware 401 from an older hub (which lacks the endpoint AND its
@@ -241282,6 +242453,8 @@ var createServer = async (opts) => {
241282
242453
  server.register(settings_routes_default);
241283
242454
  server.register(translate_routes_default);
241284
242455
  server.register(event_routes_default);
242456
+ server.register(notification_routes_default);
242457
+ server.register(notification_outbox_routes_default);
241285
242458
  server.register(terminal_routes_default);
241286
242459
  server.register(browser_routes_default);
241287
242460
  server.register(browser_proxy_routes_default);
@@ -243623,7 +244796,7 @@ var connectRoutes = buildRouteMap({
243623
244796
  defaultCommand: "run",
243624
244797
  docs: { brief: "Connect to a Vibedeckx server as an inbound node" }
243625
244798
  });
243626
- var routes30 = buildRouteMap({
244799
+ var routes32 = buildRouteMap({
243627
244800
  routes: {
243628
244801
  start: startCommand,
243629
244802
  connect: connectRoutes
@@ -243633,7 +244806,7 @@ var routes30 = buildRouteMap({
243633
244806
  brief: "Vibedeckx - AI-powered app generator"
243634
244807
  }
243635
244808
  });
243636
- var program = buildApplication(routes30, {
244809
+ var program = buildApplication(routes32, {
243637
244810
  name: "vibedeckx",
243638
244811
  versionInfo: {
243639
244812
  currentVersion: readPackageVersion()