@vibedeckx/darwin-arm64 0.2.15 → 0.2.17

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 +1690 -267
  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,
@@ -185596,6 +185613,7 @@ var mapAgentSession = (row) => ({
185596
185613
  permission_mode: row.permission_mode ?? void 0,
185597
185614
  agent_type: row.agent_type ?? void 0,
185598
185615
  title: row.title,
185616
+ model: row.model ?? null,
185599
185617
  created_at: row.created_at,
185600
185618
  updated_at: row.updated_at,
185601
185619
  last_user_message_at: row.last_user_message_at,
@@ -185610,7 +185628,7 @@ var createAgentSessionRepos = (kdb, h) => ({
185610
185628
  // writes — this is what lets getLatestByBranch break ties
185611
185629
  // deterministically (see the schema.ts / sqlite.ts DDL comment on
185612
185630
  // agent_sessions).
185613
- create: async ({ id, project_id, branch, permission_mode, agent_type }) => {
185631
+ create: async ({ id, project_id, branch, permission_mode, agent_type, model }) => {
185614
185632
  await kdb.insertInto("agent_sessions").values({
185615
185633
  id,
185616
185634
  project_id,
@@ -185618,6 +185636,7 @@ var createAgentSessionRepos = (kdb, h) => ({
185618
185636
  status: "running",
185619
185637
  permission_mode: permission_mode ?? "edit",
185620
185638
  agent_type: agent_type ?? "claude-code",
185639
+ model: model ?? null,
185621
185640
  created_at: h.nowMs(),
185622
185641
  updated_at: h.nowMs()
185623
185642
  }).execute();
@@ -185660,6 +185679,9 @@ var createAgentSessionRepos = (kdb, h) => ({
185660
185679
  updateAgentType: async (id, agent_type) => {
185661
185680
  await kdb.updateTable("agent_sessions").set({ agent_type, updated_at: h.nowMs() }).where("id", "=", id).execute();
185662
185681
  },
185682
+ updateModel: async (id, model) => {
185683
+ await kdb.updateTable("agent_sessions").set({ model, updated_at: h.nowMs() }).where("id", "=", id).execute();
185684
+ },
185663
185685
  updateTitle: async (id, title) => {
185664
185686
  await kdb.updateTable("agent_sessions").set({ title, updated_at: h.nowMs() }).where("id", "=", id).execute();
185665
185687
  },
@@ -185690,6 +185712,17 @@ var createAgentSessionRepos = (kdb, h) => ({
185690
185712
  upsertEntry: async (sessionId, entryIndex, data) => {
185691
185713
  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
185714
  },
185715
+ // One transaction, both writes idempotent — see the contract note in
185716
+ // types.ts for why the milestone must ride with the turn_end rather than
185717
+ // being written next to it.
185718
+ upsertTurnEndWithOutbox: async ({ sessionId, entryIndex, entryData, outbox }) => {
185719
+ await kdb.transaction().execute(async (trx) => {
185720
+ 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();
185721
+ if (outbox) {
185722
+ await trx.insertInto("notification_outbox").values(outbox).onConflict((oc) => oc.column("id").doNothing()).execute();
185723
+ }
185724
+ });
185725
+ },
185693
185726
  getEntries: async (sessionId) => {
185694
185727
  return kdb.selectFrom("agent_session_entries").select(["entry_index", "data"]).where("session_id", "=", sessionId).orderBy("entry_index", "asc").execute();
185695
185728
  },
@@ -185703,14 +185736,21 @@ var createAgentSessionRepos = (kdb, h) => ({
185703
185736
  remoteSessionMappings: {
185704
185737
  // ON CONFLICT SET deliberately omits title_resolved — a re-upsert (e.g.
185705
185738
  // 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) => {
185739
+ // the "AI title already generated" flag back to false. It omits
185740
+ // notification_sync_start and notification_watch_until for the same reason:
185741
+ // re-mapping a session (reconnect) or reusing a reviewer for a second
185742
+ // review must not downgrade an established from_start policy, and must not
185743
+ // rewind the watch window mid-flight. The sync cursor lives in its own
185744
+ // table and is likewise untouched — a reused reviewer continues from the
185745
+ // boundary it already imported instead of replaying its whole history.
185746
+ upsert: async (localSessionId, projectId, remoteServerId, remoteSessionId, branch, notificationSyncStart) => {
185708
185747
  await kdb.insertInto("remote_session_mappings").values({
185709
185748
  local_session_id: localSessionId,
185710
185749
  project_id: projectId,
185711
185750
  remote_server_id: remoteServerId,
185712
185751
  remote_session_id: remoteSessionId,
185713
- branch
185752
+ branch,
185753
+ notification_sync_start: notificationSyncStart ?? "from_now"
185714
185754
  }).onConflict((oc) => oc.column("local_session_id").doUpdateSet({
185715
185755
  project_id: projectId,
185716
185756
  remote_server_id: remoteServerId,
@@ -185719,10 +185759,43 @@ var createAgentSessionRepos = (kdb, h) => ({
185719
185759
  })).execute();
185720
185760
  },
185721
185761
  getAll: async () => {
185722
- return kdb.selectFrom("remote_session_mappings").select(["local_session_id", "project_id", "remote_server_id", "remote_session_id", "branch"]).execute();
185762
+ const rows = await kdb.selectFrom("remote_session_mappings").selectAll().execute();
185763
+ return rows.map(mapRemoteSessionMapping);
185764
+ },
185765
+ getByLocal: async (localSessionId) => {
185766
+ const row = await kdb.selectFrom("remote_session_mappings").selectAll().where("local_session_id", "=", localSessionId).executeTakeFirst();
185767
+ return row ? mapRemoteSessionMapping(row) : void 0;
185768
+ },
185769
+ getByRemote: async (remoteServerId, remoteSessionId) => {
185770
+ const row = await kdb.selectFrom("remote_session_mappings").selectAll().where("remote_server_id", "=", remoteServerId).where("remote_session_id", "=", remoteSessionId).executeTakeFirst();
185771
+ return row ? mapRemoteSessionMapping(row) : void 0;
185723
185772
  },
185773
+ // MAX(existing, incoming): concurrent extenders (a new turn plus live
185774
+ // stream activity) must not shorten a longer window one of them already set.
185775
+ extendNotificationWatch: async (localSessionId, until) => {
185776
+ await kdb.updateTable("remote_session_mappings").set({
185777
+ notification_watch_until: sql`MAX(COALESCE(notification_watch_until, 0), ${until})`
185778
+ }).where("local_session_id", "=", localSessionId).execute();
185779
+ },
185780
+ getNotificationSyncCandidates: async ({ now: now2, includeExpired }) => {
185781
+ let query = kdb.selectFrom("remote_session_mappings").selectAll();
185782
+ if (!includeExpired) {
185783
+ query = query.where("notification_watch_until", ">", now2);
185784
+ }
185785
+ const rows = await query.orderBy("local_session_id", "asc").execute();
185786
+ return rows.map(mapRemoteSessionMapping);
185787
+ },
185788
+ // The cursor is deleted with the mapping: without a mapping the front has
185789
+ // no local target for the worker's events, so a stale cursor would only
185790
+ // suppress a legitimate re-mapping's from_start replay.
185724
185791
  delete: async (localSessionId) => {
185725
- await kdb.deleteFrom("remote_session_mappings").where("local_session_id", "=", localSessionId).execute();
185792
+ await kdb.transaction().execute(async (trx) => {
185793
+ const row = await trx.selectFrom("remote_session_mappings").select(["remote_server_id", "remote_session_id"]).where("local_session_id", "=", localSessionId).executeTakeFirst();
185794
+ await trx.deleteFrom("remote_session_mappings").where("local_session_id", "=", localSessionId).execute();
185795
+ if (row) {
185796
+ await trx.deleteFrom("notification_sync_cursors").where("remote_server_id", "=", row.remote_server_id).where("remote_session_id", "=", row.remote_session_id).execute();
185797
+ }
185798
+ });
185726
185799
  },
185727
185800
  isTitleResolved: async (localSessionId) => {
185728
185801
  const row = await kdb.selectFrom("remote_session_mappings").select("title_resolved").where("local_session_id", "=", localSessionId).executeTakeFirst();
@@ -186248,6 +186321,18 @@ var createWorkflowRunRepos = (kdb) => ({
186248
186321
  transition: async (id, from, to, patch) => {
186249
186322
  const result = await kdb.updateTable("workflow_runs").set({ ...patch ?? {}, status: to, updated_at: sql`datetime('now')` }).where("id", "=", id).where("status", "=", from).executeTakeFirst();
186250
186323
  return (result.numUpdatedRows ?? 0n) > 0n;
186324
+ },
186325
+ // Guarded update FIRST, then the outbox insert conditioned on it having
186326
+ // applied: that ordering is what makes a lost CAS silent. Both statements
186327
+ // share one transaction so a crash between them can't leave a notified
186328
+ // transition unpersisted (or vice versa).
186329
+ transitionWithOutbox: async (id, from, to, patch, outbox) => {
186330
+ return kdb.transaction().execute(async (trx) => {
186331
+ const result = await trx.updateTable("workflow_runs").set({ ...patch ?? {}, status: to, updated_at: sql`datetime('now')` }).where("id", "=", id).where("status", "=", from).executeTakeFirst();
186332
+ if ((result.numUpdatedRows ?? 0n) === 0n) return false;
186333
+ await trx.insertInto("notification_outbox").values(outbox).onConflict((oc) => oc.column("id").doNothing()).execute();
186334
+ return true;
186335
+ });
186251
186336
  }
186252
186337
  }
186253
186338
  });
@@ -186277,6 +186362,157 @@ var createTurnSnapshotRepos = (kdb) => ({
186277
186362
  }
186278
186363
  });
186279
186364
 
186365
+ // src/storage/repositories/notifications.ts
186366
+ var mapOutbox = (row) => ({
186367
+ seq: row.seq,
186368
+ id: row.id,
186369
+ kind: row.kind,
186370
+ project_id: row.project_id,
186371
+ branch: row.branch,
186372
+ session_id: row.session_id,
186373
+ workflow_run_id: row.workflow_run_id,
186374
+ created_at: row.created_at
186375
+ });
186376
+ var mapNotification = (row) => ({
186377
+ id: row.id,
186378
+ user_id: row.user_id,
186379
+ kind: row.kind,
186380
+ project_id: row.project_id,
186381
+ branch: row.branch,
186382
+ session_id: row.session_id,
186383
+ workflow_run_id: row.workflow_run_id,
186384
+ title: row.title,
186385
+ body: row.body,
186386
+ created_at: row.created_at,
186387
+ read_at: row.read_at
186388
+ });
186389
+ var createNotificationRepos = (kdb) => ({
186390
+ notificationOutbox: {
186391
+ // ON CONFLICT(id) DO NOTHING is what makes retrying the *business*
186392
+ // transition safe: the milestone id is derived from durable state
186393
+ // (session + turn_end index, or workflow run id), so a retried transaction
186394
+ // re-derives the same id and the second insert is a no-op rather than a
186395
+ // second ding. `inserted: false` still returns the existing seq so callers
186396
+ // that want to trigger a drain can do so either way.
186397
+ insert: async (event) => {
186398
+ const result = await kdb.insertInto("notification_outbox").values(event).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186399
+ const inserted = (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186400
+ const row = await kdb.selectFrom("notification_outbox").select("seq").where("id", "=", event.id).executeTakeFirst();
186401
+ return { inserted, seq: row?.seq ?? null };
186402
+ },
186403
+ listAfter: async (afterSeq, limit) => {
186404
+ const rows = await kdb.selectFrom("notification_outbox").selectAll().where("seq", ">", afterSeq).orderBy("seq", "asc").limit(limit).execute();
186405
+ return rows.map(mapOutbox);
186406
+ },
186407
+ listBySessionAfter: async (sessionId, afterSeq, limit) => {
186408
+ const rows = await kdb.selectFrom("notification_outbox").selectAll().where("session_id", "=", sessionId).where("seq", ">", afterSeq).orderBy("seq", "asc").limit(limit).execute();
186409
+ return rows.map(mapOutbox);
186410
+ },
186411
+ headBySession: async (sessionId) => {
186412
+ const row = await kdb.selectFrom("notification_outbox").select(kdb.fn.max("seq").as("head")).where("session_id", "=", sessionId).executeTakeFirst();
186413
+ return row?.head ?? 0;
186414
+ },
186415
+ head: async () => {
186416
+ const row = await kdb.selectFrom("notification_outbox").select(kdb.fn.max("seq").as("head")).executeTakeFirst();
186417
+ return row?.head ?? 0;
186418
+ },
186419
+ pruneOlderThan: async (cutoffMs) => {
186420
+ await kdb.deleteFrom("notification_outbox").where("created_at", "<", cutoffMs).execute();
186421
+ }
186422
+ },
186423
+ notifications: {
186424
+ insert: async (notification) => {
186425
+ const result = await kdb.insertInto("notifications").values(notification).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186426
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186427
+ },
186428
+ // One transaction on purpose. Insert-then-crash-then-replay is safe (the id
186429
+ // unique constraint absorbs it), but cursor-advance-then-crash would lose
186430
+ // the notification permanently — so the two writes must commit together.
186431
+ importRemote: async ({ notification, remoteServerId, remoteSessionId, seq }) => {
186432
+ return kdb.transaction().execute(async (trx) => {
186433
+ const result = await trx.insertInto("notifications").values(notification).onConflict((oc) => oc.column("id").doNothing()).executeTakeFirst();
186434
+ await trx.insertInto("notification_sync_cursors").values({
186435
+ remote_server_id: remoteServerId,
186436
+ remote_session_id: remoteSessionId,
186437
+ last_seq: seq,
186438
+ updated_at: Date.now()
186439
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doUpdateSet({
186440
+ // MAX(existing, incoming) — pages can be retried out of order after
186441
+ // a transient remote error, and a cursor that moved backward would
186442
+ // re-import (and re-ding) everything after it.
186443
+ last_seq: sql`MAX(notification_sync_cursors.last_seq, excluded.last_seq)`,
186444
+ updated_at: Date.now()
186445
+ })).execute();
186446
+ return { inserted: (result.numInsertedOrUpdatedRows ?? 0n) > 0n };
186447
+ });
186448
+ },
186449
+ listForUser: async (userId, opts) => {
186450
+ let query = kdb.selectFrom("notifications").selectAll().where("user_id", "=", userId);
186451
+ if (opts.unreadOnly) query = query.where("read_at", "is", null);
186452
+ const rows = await query.orderBy("created_at", "desc").orderBy("id", "desc").limit(opts.limit).execute();
186453
+ return rows.map(mapNotification);
186454
+ },
186455
+ // The user_id predicate is load-bearing authorization, not a filter: a
186456
+ // caller must not be able to mutate — or learn the existence of — another
186457
+ // tenant's notification by guessing its (deterministic, guessable) id.
186458
+ markRead: async (id, userId) => {
186459
+ const result = await kdb.updateTable("notifications").set({ read_at: Date.now() }).where("id", "=", id).where("user_id", "=", userId).where("read_at", "is", null).executeTakeFirst();
186460
+ if ((result.numUpdatedRows ?? 0n) > 0n) return true;
186461
+ const existing = await kdb.selectFrom("notifications").select("id").where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
186462
+ return existing !== void 0;
186463
+ },
186464
+ markAllRead: async (userId) => {
186465
+ await kdb.updateTable("notifications").set({ read_at: Date.now() }).where("user_id", "=", userId).where("read_at", "is", null).execute();
186466
+ },
186467
+ // Unread rows are never deleted — an unread milestone is the whole point of
186468
+ // the feature. Read history is capped per user so it can't grow forever.
186469
+ cleanup: async (keepRead) => {
186470
+ const users = await kdb.selectFrom("notifications").select("user_id").where("read_at", "is not", null).groupBy("user_id").execute();
186471
+ for (const { user_id } of users) {
186472
+ 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();
186473
+ let query = kdb.deleteFrom("notifications").where("user_id", "=", user_id).where("read_at", "is not", null);
186474
+ if (keep.length > 0) {
186475
+ query = query.where("id", "not in", keep.map((r) => r.id));
186476
+ }
186477
+ await query.execute();
186478
+ }
186479
+ }
186480
+ },
186481
+ notificationSyncCursors: {
186482
+ get: async (remoteServerId, remoteSessionId) => {
186483
+ const row = await kdb.selectFrom("notification_sync_cursors").select("last_seq").where("remote_server_id", "=", remoteServerId).where("remote_session_id", "=", remoteSessionId).executeTakeFirst();
186484
+ return row?.last_seq;
186485
+ },
186486
+ getMany: async (remoteServerId, remoteSessionIds) => {
186487
+ if (remoteSessionIds.length === 0) return /* @__PURE__ */ new Map();
186488
+ 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();
186489
+ return new Map(rows.map((r) => [r.remote_session_id, r.last_seq]));
186490
+ },
186491
+ set: async (remoteServerId, remoteSessionId, lastSeq) => {
186492
+ await kdb.insertInto("notification_sync_cursors").values({
186493
+ remote_server_id: remoteServerId,
186494
+ remote_session_id: remoteSessionId,
186495
+ last_seq: lastSeq,
186496
+ updated_at: Date.now()
186497
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doUpdateSet({
186498
+ last_seq: sql`MAX(notification_sync_cursors.last_seq, excluded.last_seq)`,
186499
+ updated_at: Date.now()
186500
+ })).execute();
186501
+ },
186502
+ // DO NOTHING, not DO UPDATE: see the contract note in types.ts for why a
186503
+ // baseline must never overwrite an existing cursor.
186504
+ initializeIfAbsent: async (remoteServerId, remoteSessionId, lastSeq) => {
186505
+ const result = await kdb.insertInto("notification_sync_cursors").values({
186506
+ remote_server_id: remoteServerId,
186507
+ remote_session_id: remoteSessionId,
186508
+ last_seq: lastSeq,
186509
+ updated_at: Date.now()
186510
+ }).onConflict((oc) => oc.columns(["remote_server_id", "remote_session_id"]).doNothing()).executeTakeFirst();
186511
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
186512
+ }
186513
+ }
186514
+ });
186515
+
186280
186516
  // src/storage/sqlite.ts
186281
186517
  var createDatabase = (dbPath) => {
186282
186518
  const db = new Database(dbPath);
@@ -186360,6 +186596,10 @@ var createDatabase = (dbPath) => {
186360
186596
  branch TEXT NOT NULL DEFAULT '',
186361
186597
  status TEXT NOT NULL DEFAULT 'running',
186362
186598
  title TEXT DEFAULT NULL,
186599
+ -- Per-session agent model, e.g. 'opus' or 'gpt-5.6-sol'. NULL = use the
186600
+ -- CLI's own default (no flag is passed). Never validated: an unknown
186601
+ -- name is passed to the CLI and fails there.
186602
+ model TEXT DEFAULT NULL,
186363
186603
  -- Millisecond-precision timestamps. CURRENT_TIMESTAMP is seconds-only,
186364
186604
  -- which lets two sessions tie on updated_at within the same second and
186365
186605
  -- corrupts the ordering used by getLatestByBranch. The 'YYYY-MM-DD
@@ -186632,6 +186872,10 @@ var createDatabase = (dbPath) => {
186632
186872
  if (!sessionInfoV6.some((col) => col.name === "favorited_at")) {
186633
186873
  db.exec("ALTER TABLE agent_sessions ADD COLUMN favorited_at INTEGER DEFAULT NULL");
186634
186874
  }
186875
+ const sessionInfoV7 = db.prepare("PRAGMA table_info(agent_sessions)").all();
186876
+ if (!sessionInfoV7.some((col) => col.name === "model")) {
186877
+ db.exec("ALTER TABLE agent_sessions ADD COLUMN model TEXT DEFAULT NULL");
186878
+ }
186635
186879
  db.exec(`
186636
186880
  CREATE INDEX IF NOT EXISTS idx_agent_sessions_project_branch
186637
186881
  ON agent_sessions(project_id, branch);
@@ -186975,7 +187219,66 @@ var createDatabase = (dbPath) => {
186975
187219
  PRIMARY KEY (session_id, turn_end_index),
186976
187220
  FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
186977
187221
  );
187222
+
187223
+ -- Durable attention-milestone outbox of THIS server. Written in the same
187224
+ -- transaction as the state that proves the milestone (a turn_end entry, a
187225
+ -- workflow status transition), so a crash can never leave a completed turn
187226
+ -- without its notification. Deliberately carries no title/body: the
187227
+ -- user-facing server generates copy at import time from its own session
187228
+ -- mapping, so a stale worker-side title never enters the wire protocol.
187229
+ -- No FK to agent_sessions: an outbox row must outlive deletion of the
187230
+ -- session it describes (deleting a session mid-sync must not silently drop
187231
+ -- an already-produced milestone). seq is the cursor, not created_at.
187232
+ CREATE TABLE IF NOT EXISTS notification_outbox (
187233
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
187234
+ id TEXT NOT NULL UNIQUE,
187235
+ kind TEXT NOT NULL,
187236
+ project_id TEXT NOT NULL,
187237
+ branch TEXT,
187238
+ session_id TEXT NOT NULL,
187239
+ workflow_run_id TEXT,
187240
+ created_at INTEGER NOT NULL
187241
+ );
187242
+
187243
+ CREATE INDEX IF NOT EXISTS idx_notification_outbox_session_seq
187244
+ ON notification_outbox(session_id, seq);
187245
+
187246
+ -- User-scoped notification inbox of the user-facing server. The id is the
187247
+ -- deterministic milestone id (remote rows namespaced
187248
+ -- remote:{serverId}:{outboxEventId}), which is what makes replay after a
187249
+ -- crash-before-cursor-advance a no-op instead of a duplicate ding.
187250
+ CREATE TABLE IF NOT EXISTS notifications (
187251
+ id TEXT PRIMARY KEY,
187252
+ user_id TEXT NOT NULL,
187253
+ kind TEXT NOT NULL,
187254
+ project_id TEXT NOT NULL,
187255
+ branch TEXT,
187256
+ session_id TEXT,
187257
+ workflow_run_id TEXT,
187258
+ title TEXT NOT NULL,
187259
+ body TEXT,
187260
+ created_at INTEGER NOT NULL,
187261
+ read_at INTEGER
187262
+ );
187263
+
187264
+ CREATE INDEX IF NOT EXISTS idx_notifications_user_created
187265
+ ON notifications(user_id, created_at DESC);
187266
+
187267
+ CREATE TABLE IF NOT EXISTS notification_sync_cursors (
187268
+ remote_server_id TEXT NOT NULL,
187269
+ remote_session_id TEXT NOT NULL,
187270
+ last_seq INTEGER NOT NULL,
187271
+ updated_at INTEGER NOT NULL,
187272
+ PRIMARY KEY (remote_server_id, remote_session_id)
187273
+ );
186978
187274
  `);
187275
+ const remoteMappingNotifyInfo = db.prepare("PRAGMA table_info(remote_session_mappings)").all();
187276
+ if (!remoteMappingNotifyInfo.some((col) => col.name === "notification_sync_start")) {
187277
+ db.exec("ALTER TABLE remote_session_mappings ADD COLUMN notification_sync_start TEXT NOT NULL DEFAULT 'from_now'");
187278
+ }
187279
+ if (!remoteMappingNotifyInfo.some((col) => col.name === "notification_watch_until")) {
187280
+ db.exec("ALTER TABLE remote_session_mappings ADD COLUMN notification_watch_until INTEGER");
187281
+ }
186979
187282
  const sessionSearchCacheInfo = db.prepare("PRAGMA table_info(session_search_cache)").all();
186980
187283
  if (!sessionSearchCacheInfo.some((col) => col.name === "written_at")) {
186981
187284
  db.exec("ALTER TABLE session_search_cache ADD COLUMN written_at INTEGER");
@@ -187022,6 +187325,7 @@ var createSqliteStorage = async (dbPath) => {
187022
187325
  ...createSearchCacheRepos(kdb, h),
187023
187326
  ...createWorkflowRunRepos(kdb),
187024
187327
  ...createTurnSnapshotRepos(kdb),
187328
+ ...createNotificationRepos(kdb),
187025
187329
  close: async () => {
187026
187330
  await kdb.destroy();
187027
187331
  }
@@ -187038,7 +187342,7 @@ import path17 from "path";
187038
187342
  import { fileURLToPath } from "url";
187039
187343
 
187040
187344
  // src/plugins/shared-services.ts
187041
- var import_fastify_plugin = __toESM(require_plugin2(), 1);
187345
+ var import_fastify_plugin2 = __toESM(require_plugin2(), 1);
187042
187346
 
187043
187347
  // src/process-manager.ts
187044
187348
  import { spawn } from "child_process";
@@ -201019,8 +201323,11 @@ var UserInputParamsSchema = external_exports.looseObject({
201019
201323
 
201020
201324
  // src/protocol/codex/cli.ts
201021
201325
  var CROSS_REMOTE_MCP_TOKEN_ENV = "VIBEDECKX_CROSS_REMOTE_MCP_TOKEN";
201022
- function buildCodexAppServerSpawnConfig(nativeBinary, crossRemoteMcp) {
201326
+ function buildCodexAppServerSpawnConfig(nativeBinary, crossRemoteMcp, model) {
201023
201327
  const args = ["app-server"];
201328
+ if (model && model.trim()) {
201329
+ args.push("-c", `model=${JSON.stringify(model.trim())}`);
201330
+ }
201024
201331
  if (crossRemoteMcp) {
201025
201332
  args.push(
201026
201333
  "-c",
@@ -201105,11 +201412,16 @@ function withNpxFallback(nativeBinary, args) {
201105
201412
  }
201106
201413
  return { command: "npx", args: ["-y", CLAUDE_NPM_PACKAGE, ...args] };
201107
201414
  }
201108
- function buildClaudeSessionSpawnConfig(nativeBinary, permissionMode, mcpConfigArg) {
201415
+ function buildClaudeSessionSpawnConfig(nativeBinary, permissionMode, mcpConfigArg, model) {
201109
201416
  const permissionFlag = permissionMode === "plan" ? "--permission-mode=plan" : "--dangerously-skip-permissions";
201110
201417
  const args = [
201111
201418
  ...STREAM_JSON_ARGS,
201112
- permissionFlag,
201419
+ permissionFlag
201420
+ ];
201421
+ if (model && model.trim()) {
201422
+ args.push("--model", model.trim());
201423
+ }
201424
+ args.push(
201113
201425
  // AskUserQuestion can't work over piped (non-TTY) stdin: claude resolves it
201114
201426
  // internally as "dismissed" before we can present a picker and wait for the
201115
201427
  // user. Disable it so the agent falls back to asking in plain text, which the
@@ -201117,7 +201429,7 @@ function buildClaudeSessionSpawnConfig(nativeBinary, permissionMode, mcpConfigAr
201117
201429
  "--disallowedTools",
201118
201430
  "AskUserQuestion",
201119
201431
  "--verbose"
201120
- ];
201432
+ );
201121
201433
  if (mcpConfigArg) {
201122
201434
  args.push("--mcp-config", mcpConfigArg);
201123
201435
  }
@@ -202016,6 +202328,57 @@ import { spawn as spawn3 } from "child_process";
202016
202328
  import { randomUUID } from "crypto";
202017
202329
  import { existsSync as existsSync3 } from "fs";
202018
202330
 
202331
+ // src/notification-milestones.ts
202332
+ var sessionResultReadyId = (sessionId, turnEndEntryIndex) => `session:${sessionId}:turn:${turnEndEntryIndex}:result-ready`;
202333
+ var sessionFailedId = (sessionId, turnEndEntryIndex) => `session:${sessionId}:turn:${turnEndEntryIndex}:failed`;
202334
+ var reviewReadyId = (workflowRunId) => `workflow:${workflowRunId}:review-ready`;
202335
+ var workflowFailedId = (workflowRunId, stateVersion) => `workflow:${workflowRunId}:failed:${stateVersion}`;
202336
+ function findTurnOpeningUserEntry(entries, beforeIndex) {
202337
+ let opening;
202338
+ for (let i = Math.min(beforeIndex, entries.length) - 1; i >= 0; i--) {
202339
+ const entry = entries[i];
202340
+ if (!entry) continue;
202341
+ if (entry.type === "turn_end") break;
202342
+ if (entry.type === "user") opening = entry;
202343
+ }
202344
+ return opening;
202345
+ }
202346
+ function resolveNotificationDisposition(openingUserEntry) {
202347
+ if (openingUserEntry?.type !== "user") return "result";
202348
+ if (openingUserEntry.notificationDisposition) return openingUserEntry.notificationDisposition;
202349
+ return openingUserEntry.origin === "workflow" ? "internal" : "result";
202350
+ }
202351
+ var FAILURE_OUTCOMES = /* @__PURE__ */ new Set([
202352
+ "failed",
202353
+ "process_exit",
202354
+ "server_restart"
202355
+ ]);
202356
+ function sessionMilestoneForTurnEnd(opts) {
202357
+ if (opts.disposition !== "result") return void 0;
202358
+ const base = {
202359
+ project_id: opts.projectId,
202360
+ branch: opts.branch,
202361
+ session_id: opts.sessionId,
202362
+ workflow_run_id: null,
202363
+ created_at: opts.createdAt
202364
+ };
202365
+ if (opts.outcome === "completed") {
202366
+ return {
202367
+ ...base,
202368
+ id: sessionResultReadyId(opts.sessionId, opts.entryIndex),
202369
+ kind: "session_result_ready"
202370
+ };
202371
+ }
202372
+ if (FAILURE_OUTCOMES.has(opts.outcome)) {
202373
+ return {
202374
+ ...base,
202375
+ id: sessionFailedId(opts.sessionId, opts.entryIndex),
202376
+ kind: "session_failed"
202377
+ };
202378
+ }
202379
+ return void 0;
202380
+ }
202381
+
202019
202382
  // src/utils/cross-remote-token.ts
202020
202383
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
202021
202384
  var CROSS_REMOTE_SECRET_SETTING = "cross_remote_token_secret";
@@ -202159,11 +202522,12 @@ var ClaudeCodeProvider = class {
202159
202522
  isAvailable() {
202160
202523
  return true;
202161
202524
  }
202162
- buildSpawnConfig(_cwd, permissionMode, crossRemoteMcp) {
202525
+ buildSpawnConfig(_cwd, permissionMode, crossRemoteMcp, model) {
202163
202526
  return buildClaudeSessionSpawnConfig(
202164
202527
  this.detectBinary(),
202165
202528
  permissionMode,
202166
- crossRemoteMcp ? buildMcpConfigArg(crossRemoteMcp) : void 0
202529
+ crossRemoteMcp ? buildMcpConfigArg(crossRemoteMcp) : void 0,
202530
+ model
202167
202531
  );
202168
202532
  }
202169
202533
  parseStdoutLine(line, _sessionId) {
@@ -202353,9 +202717,9 @@ var CodexProvider = class _CodexProvider {
202353
202717
  isAvailable() {
202354
202718
  return true;
202355
202719
  }
202356
- buildSpawnConfig(_cwd, permissionMode, crossRemoteMcp) {
202720
+ buildSpawnConfig(_cwd, permissionMode, crossRemoteMcp, model) {
202357
202721
  this.lastPermissionMode = permissionMode;
202358
- return buildCodexAppServerSpawnConfig(this.detectBinary(), crossRemoteMcp);
202722
+ return buildCodexAppServerSpawnConfig(this.detectBinary(), crossRemoteMcp, model);
202359
202723
  }
202360
202724
  // ============ Task 5.5: parseStdoutLine — JSON-RPC message routing ============
202361
202725
  parseStdoutLine(line, sessionId) {
@@ -224253,6 +224617,21 @@ var TurnCompletionLedger = class {
224253
224617
  };
224254
224618
 
224255
224619
  // src/agent-session-manager.ts
224620
+ function buildStartupFailureMessage(agentType, stderrTail, stdoutTail) {
224621
+ const provider = getProvider(agentType);
224622
+ const name25 = provider.getDisplayName();
224623
+ const details = [stdoutTail.trim(), stderrTail.trim()].filter(Boolean).join("\n");
224624
+ const hint = stdoutTail.trim() ? void 0 : provider.getInstallHint?.();
224625
+ let msg = `Couldn't start ${name25}.`;
224626
+ if (hint) msg += `
224627
+
224628
+ ${hint}`;
224629
+ if (details) msg += `
224630
+
224631
+ Details:
224632
+ ${details}`;
224633
+ return msg;
224634
+ }
224256
224635
  var SUMMARY_TEXT_CAP = 1500;
224257
224636
  function extractLastAssistantText(entries) {
224258
224637
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -224295,6 +224674,36 @@ var AgentSessionManager = class {
224295
224674
  setEventBus(eventBus) {
224296
224675
  this.eventBus = eventBus;
224297
224676
  }
224677
+ /**
224678
+ * Injected by shared-services: nudges NotificationService to drain the local
224679
+ * outbox right after a milestone lands, so the bell is fast in the common
224680
+ * case. Purely a latency optimization — correctness comes from the periodic
224681
+ * and startup drains, because this hook can't fire if the process dies
224682
+ * between the commit and the import.
224683
+ */
224684
+ setMilestoneListener(listener) {
224685
+ this.onMilestoneCreated = listener;
224686
+ }
224687
+ onMilestoneCreated = null;
224688
+ /**
224689
+ * Decide the disposition to persist on an outgoing user turn.
224690
+ *
224691
+ * Ordinary user input defaults to `result`. Workflow callers must be explicit:
224692
+ * `origin: "workflow"` alone is NOT enough to infer `internal`, because
224693
+ * approved review feedback is also a workflow-authored turn yet starts a
224694
+ * user-visible source result. An omission is therefore a caller bug — warn
224695
+ * loudly rather than silently picking a disposition that suppresses a real
224696
+ * notification.
224697
+ */
224698
+ resolveOutgoingDisposition(sessionId, opts) {
224699
+ if (opts?.notificationDisposition) return opts.notificationDisposition;
224700
+ if (opts?.origin === "workflow") {
224701
+ console.warn(
224702
+ `[AgentSession] workflow-origin turn for ${sessionId} carries no explicit notificationDisposition; defaulting to "result"`
224703
+ );
224704
+ }
224705
+ return "result";
224706
+ }
224298
224707
  /**
224299
224708
  * Injected by shared-services: lets commitCompletion mark taskCompleted WS
224300
224709
  * frames whose completion the local WorkflowEngine claims (reviewer
@@ -224529,6 +224938,7 @@ var AgentSessionManager = class {
224529
224938
  await this.ensureResidentCapacity({ projectId, branch }, { force });
224530
224939
  const sessionId = opts.sessionId ?? randomUUID();
224531
224940
  const branchKey = branch ?? "";
224941
+ const model = opts.model?.trim() ? opts.model.trim() : null;
224532
224942
  const absoluteWorktreePath = resolveWorktreePath(projectPath, branch);
224533
224943
  if (!skipDb) {
224534
224944
  await this.storage.agentSessions.create({
@@ -224536,7 +224946,8 @@ var AgentSessionManager = class {
224536
224946
  project_id: projectId,
224537
224947
  branch: branchKey,
224538
224948
  permission_mode: permissionMode,
224539
- agent_type: agentType
224949
+ agent_type: agentType,
224950
+ model
224540
224951
  });
224541
224952
  }
224542
224953
  if (!skipDb) {
@@ -224564,13 +224975,15 @@ var AgentSessionManager = class {
224564
224975
  permissionMode,
224565
224976
  crossRemoteMcp: opts.crossRemoteMcp,
224566
224977
  agentType,
224978
+ model,
224567
224979
  completion: new TurnCompletionLedger(),
224568
224980
  graceTimer: null,
224569
224981
  eventChain: Promise.resolve(),
224570
224982
  bgSpawnHintsThisTurn: 0,
224571
224983
  taskStartedThisTurn: 0,
224572
224984
  lastActiveAt: Date.now(),
224573
- turnOpenSince: null
224985
+ turnOpenSince: null,
224986
+ turnDisposition: null
224574
224987
  };
224575
224988
  this.sessions.set(sessionId, session);
224576
224989
  const provider = getProvider(agentType);
@@ -224653,7 +225066,7 @@ var AgentSessionManager = class {
224653
225066
  this.broadcastRaw(session.id, { finished: true });
224654
225067
  return;
224655
225068
  }
224656
- const config2 = provider.buildSpawnConfig(cwd, session.permissionMode, session.crossRemoteMcp);
225069
+ const config2 = provider.buildSpawnConfig(cwd, session.permissionMode, session.crossRemoteMcp, session.model);
224657
225070
  if (config2.command !== "npx") {
224658
225071
  const agentVersion = getBinaryVersion(config2.command);
224659
225072
  console.log(`[AgentSession] ${provider.getDisplayName()} version: ${agentVersion ?? "unknown (--version probe failed)"}`);
@@ -224661,6 +225074,7 @@ var AgentSessionManager = class {
224661
225074
  console.log(`[AgentSession] ${provider.getDisplayName()} running via npx (version resolved at spawn by npm)`);
224662
225075
  }
224663
225076
  session.producedOutput = false;
225077
+ session.unparsedStdoutTail = "";
224664
225078
  this.resetCompletion(session);
224665
225079
  let stderrTail = "";
224666
225080
  let spawnFailed = false;
@@ -224709,7 +225123,7 @@ var AgentSessionManager = class {
224709
225123
  try {
224710
225124
  await this.pushEntry(session.id, {
224711
225125
  type: "error",
224712
- message: this.buildStartupFailureMessage(session.agentType, stderrTail),
225126
+ message: buildStartupFailureMessage(session.agentType, stderrTail, session.unparsedStdoutTail ?? ""),
224713
225127
  timestamp: Date.now()
224714
225128
  }, true);
224715
225129
  } catch (err) {
@@ -224742,7 +225156,7 @@ var AgentSessionManager = class {
224742
225156
  const isNotFound = error48.code === "ENOENT";
224743
225157
  this.pushEntry(session.id, {
224744
225158
  type: "error",
224745
- message: isNotFound ? this.buildStartupFailureMessage(session.agentType, stderrTail) : error48.message,
225159
+ message: isNotFound ? buildStartupFailureMessage(session.agentType, stderrTail, session.unparsedStdoutTail ?? "") : error48.message,
224746
225160
  timestamp: Date.now()
224747
225161
  }, true).catch((err) => {
224748
225162
  console.error(`[AgentSession] Failed to push spawn-error entry for ${session.id}:`, err);
@@ -224867,6 +225281,8 @@ var AgentSessionManager = class {
224867
225281
  const events = provider.parseStdoutLine(line, session.id);
224868
225282
  if (events.length > 0) {
224869
225283
  session.producedOutput = true;
225284
+ } else {
225285
+ session.unparsedStdoutTail = ((session.unparsedStdoutTail ?? "") + line + "\n").slice(-4e3);
224870
225286
  }
224871
225287
  for (const event of events) {
224872
225288
  await this.processAgentEvent(session.id, event);
@@ -225121,32 +225537,21 @@ var AgentSessionManager = class {
225121
225537
  * Push a new entry with ADD patch
225122
225538
  */
225123
225539
  /**
225124
- * Build a user-facing message for when an agent process fails to start.
225125
- * Includes the provider's install hint plus any captured stderr tail.
225540
+ * Allocate the next entry index, record the message in the in-memory store,
225541
+ * and build its ADD replay patch. Shared by `pushEntry` and `pushTurnEnd` so
225542
+ * the two persistence paths can't drift on index allocation or patch shape.
225126
225543
  */
225127
- buildStartupFailureMessage(agentType, stderrTail) {
225128
- const provider = getProvider(agentType);
225129
- const name25 = provider.getDisplayName();
225130
- const hint = provider.getInstallHint?.();
225131
- let msg = `Couldn't start ${name25}.`;
225132
- if (hint) msg += `
225133
-
225134
- ${hint}`;
225135
- const details = stderrTail.trim();
225136
- if (details) msg += `
225137
-
225138
- Details:
225139
- ${details}`;
225140
- return msg;
225544
+ stageEntry(session, message) {
225545
+ const index = session.store.indexProvider.next();
225546
+ session.store.entries[index] = message;
225547
+ const patch = ConversationPatch.addEntry(index, message);
225548
+ session.store.patches.push(patch);
225549
+ return { index, patch };
225141
225550
  }
225142
225551
  async pushEntry(sessionId, message, broadcast = true, userId = "local") {
225143
225552
  const session = this.sessions.get(sessionId);
225144
225553
  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);
225554
+ const { index, patch } = this.stageEntry(session, message);
225150
225555
  if (!session.skipDb && message.type !== "assistant") {
225151
225556
  await this.persistEntry(session, index, message, userId);
225152
225557
  }
@@ -225155,6 +225560,60 @@ ${details}`;
225155
225560
  }
225156
225561
  return index;
225157
225562
  }
225563
+ /**
225564
+ * Persist a `turn_end` together with the attention milestone it earns.
225565
+ *
225566
+ * Deliberately NOT routed through `pushEntry`: the milestone id embeds the
225567
+ * turn_end's entry index, so the index has to be allocated before the write,
225568
+ * and the write itself has to be the atomic turn-end/outbox operation rather
225569
+ * than a plain entry upsert. Ordering matches `pushEntry` (persist, then
225570
+ * broadcast) so the existing turn_end-before-status contract still holds.
225571
+ *
225572
+ * `entryIndexOverride` re-persists an already-staged boundary (crash-repair
225573
+ * replay); without it a fresh index is allocated.
225574
+ */
225575
+ async pushTurnEnd(session, outcome, disposition, endedAt, durationMs, entryIndexOverride) {
225576
+ const message = {
225577
+ type: "turn_end",
225578
+ timestamp: endedAt,
225579
+ ...durationMs !== void 0 ? { durationMs } : {},
225580
+ outcome,
225581
+ notificationDisposition: disposition
225582
+ };
225583
+ let index;
225584
+ let patch;
225585
+ if (entryIndexOverride !== void 0) {
225586
+ index = entryIndexOverride;
225587
+ patch = ConversationPatch.addEntry(index, message);
225588
+ } else {
225589
+ ({ index, patch } = this.stageEntry(session, message));
225590
+ }
225591
+ if (!session.skipDb) {
225592
+ const outbox = sessionMilestoneForTurnEnd({
225593
+ sessionId: session.id,
225594
+ projectId: session.projectId,
225595
+ branch: session.branch,
225596
+ entryIndex: index,
225597
+ outcome,
225598
+ disposition,
225599
+ createdAt: endedAt
225600
+ });
225601
+ try {
225602
+ await this.storage.agentSessions.upsertTurnEndWithOutbox({
225603
+ sessionId: session.id,
225604
+ entryIndex: index,
225605
+ entryData: JSON.stringify(message),
225606
+ outbox
225607
+ });
225608
+ await this.storage.agentSessions.touchUpdatedAt(session.id);
225609
+ if (outbox) this.onMilestoneCreated?.();
225610
+ } catch (error48) {
225611
+ console.error(`[AgentSession] Failed to persist turn_end ${index} for ${session.id}:`, error48);
225612
+ }
225613
+ }
225614
+ if (entryIndexOverride === void 0) this.broadcastPatch(session.id, patch);
225615
+ return index;
225616
+ }
225158
225617
  /**
225159
225618
  * Persist a single entry to the database
225160
225619
  */
@@ -225193,18 +225652,24 @@ ${details}`;
225193
225652
  }
225194
225653
  }
225195
225654
  /**
225196
- * Close the open turn with a persisted turn_end stop-point entry.
225655
+ * Close the open turn with a persisted turn_end stop-point entry, plus the
225656
+ * attention milestone the turn's disposition earns (written in the same
225657
+ * storage transaction — see pushTurnEnd).
225658
+ *
225197
225659
  * turn_end entries are constructed ONLY here and in repairInterruptedTurn
225198
225660
  * (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).
225661
+ * payload.duration_ms is not used.
225201
225662
  */
225202
225663
  async endActiveTurn(session, outcome) {
225203
225664
  if (session.turnOpenSince === null) return null;
225204
225665
  const endedAt = Date.now();
225205
225666
  const durationMs = endedAt - session.turnOpenSince;
225206
- const index = await this.pushEntry(session.id, { type: "turn_end", timestamp: endedAt, durationMs, outcome }, true);
225667
+ const disposition = session.turnDisposition ?? resolveNotificationDisposition(
225668
+ findTurnOpeningUserEntry(session.store.entries, session.store.entries.length)
225669
+ );
225670
+ const index = await this.pushTurnEnd(session, outcome, disposition, endedAt, durationMs);
225207
225671
  session.turnOpenSince = null;
225672
+ session.turnDisposition = null;
225208
225673
  if (!session.skipDb && index >= 0) {
225209
225674
  try {
225210
225675
  const project = await this.storage.projects.getById(session.projectId);
@@ -225223,12 +225688,13 @@ ${details}`;
225223
225688
  async sendUserMessage(sessionId, content, projectPath, userId = "local", opts) {
225224
225689
  const session = this.sessions.get(sessionId);
225225
225690
  if (!session) return false;
225691
+ const disposition = this.resolveOutgoingDisposition(sessionId, opts);
225226
225692
  if (session.dormant) {
225227
225693
  if (!projectPath) {
225228
225694
  console.error(`[AgentSession] Cannot wake dormant session ${sessionId} without projectPath`);
225229
225695
  return false;
225230
225696
  }
225231
- await this.wakeDormantSession(session, projectPath, content, userId, opts?.origin);
225697
+ await this.wakeDormantSession(session, projectPath, content, userId, opts?.origin, disposition);
225232
225698
  return true;
225233
225699
  }
225234
225700
  if (!session.process?.stdin) {
@@ -225248,7 +225714,8 @@ ${details}`;
225248
225714
  type: "user",
225249
225715
  content,
225250
225716
  timestamp: Date.now(),
225251
- ...opts?.origin ? { origin: opts.origin } : {}
225717
+ ...opts?.origin ? { origin: opts.origin } : {},
225718
+ notificationDisposition: disposition
225252
225719
  }, true, userId);
225253
225720
  try {
225254
225721
  const provider = getProvider(session.agentType);
@@ -225257,14 +225724,20 @@ ${details}`;
225257
225724
  console.warn(
225258
225725
  `[AgentSession] sendUserMessage: provider buffered the input for ${session.agentType} session ${sessionId} \u2014 no stdin payload yet`
225259
225726
  );
225260
- if (session.turnOpenSince === null) session.turnOpenSince = Date.now();
225727
+ if (session.turnOpenSince === null) {
225728
+ session.turnOpenSince = Date.now();
225729
+ session.turnDisposition = disposition;
225730
+ }
225261
225731
  return true;
225262
225732
  }
225263
225733
  console.log(
225264
225734
  `[AgentSession] sendUserMessage: wrote ${formatted.length}B to ${session.agentType} stdin (session=${sessionId})`
225265
225735
  );
225266
225736
  session.process.stdin.write(formatted);
225267
- if (session.turnOpenSince === null) session.turnOpenSince = Date.now();
225737
+ if (session.turnOpenSince === null) {
225738
+ session.turnOpenSince = Date.now();
225739
+ session.turnDisposition = disposition;
225740
+ }
225268
225741
  return true;
225269
225742
  } catch (error48) {
225270
225743
  console.error(`[AgentSession] Failed to send message:`, error48);
@@ -225554,12 +226027,19 @@ ${details}`;
225554
226027
  session.store.currentAssistantIndex = null;
225555
226028
  session.buffer = "";
225556
226029
  this.resetCompletion(session);
226030
+ const previousAgentType = session.agentType;
225557
226031
  getProvider(session.agentType).onSessionDestroyed?.(sessionId);
225558
226032
  session.agentType = agentType;
225559
226033
  if (!session.skipDb) await this.storage.agentSessions.updateAgentType(sessionId, agentType);
226034
+ const clearedModel = session.model;
226035
+ if (clearedModel !== null) {
226036
+ session.model = null;
226037
+ if (!session.skipDb) await this.storage.agentSessions.updateModel(sessionId, null);
226038
+ }
226039
+ const agentDisplayName = (t) => t === "codex" ? "Codex" : "Claude Code";
225560
226040
  await this.pushEntry(sessionId, {
225561
226041
  type: "system",
225562
- content: `Coding agent switched to ${agentType === "codex" ? "Codex" : "Claude Code"}.`,
226042
+ content: `Coding agent switched to ${agentDisplayName(agentType)}.` + (clearedModel !== null ? ` Model reset to the default (\`${clearedModel}\` was set for ${agentDisplayName(previousAgentType)}).` : ""),
225563
226043
  timestamp: Date.now()
225564
226044
  });
225565
226045
  session.dormant = true;
@@ -225691,7 +226171,7 @@ ${details}`;
225691
226171
  /**
225692
226172
  * Wake a dormant session: spawn process, send full context + user message
225693
226173
  */
225694
- async wakeDormantSession(session, projectPath, userMessage, userId = "local", origin) {
226174
+ async wakeDormantSession(session, projectPath, userMessage, userId = "local", origin, notificationDisposition = "result") {
225695
226175
  console.log(`[AgentSession] Waking dormant session ${session.id}`);
225696
226176
  await this.ensureResidentCapacity(
225697
226177
  { projectId: session.projectId, branch: session.branch },
@@ -225712,9 +226192,11 @@ ${details}`;
225712
226192
  type: "user",
225713
226193
  content: userMessage,
225714
226194
  timestamp: Date.now(),
225715
- ...origin ? { origin } : {}
226195
+ ...origin ? { origin } : {},
226196
+ notificationDisposition
225716
226197
  }, true, userId);
225717
226198
  session.turnOpenSince = Date.now();
226199
+ session.turnDisposition = notificationDisposition;
225718
226200
  setTimeout(() => {
225719
226201
  const context2 = this.buildFullConversationContext(session.store.entries);
225720
226202
  if (context2) {
@@ -225781,7 +226263,8 @@ ${details}`;
225781
226263
  * this runs on server boot for every restored session and must never
225782
226264
  * throw into the restore path.
225783
226265
  */
225784
- async repairInterruptedTurn(sessionId, rows) {
226266
+ async repairInterruptedTurn(dbSession, rows) {
226267
+ const sessionId = dbSession.id;
225785
226268
  let landingType = null;
225786
226269
  for (let i = rows.length - 1; i >= 0; i--) {
225787
226270
  try {
@@ -225796,17 +226279,43 @@ ${details}`;
225796
226279
  if (landingType === null || landingType === "turn_end") return rows;
225797
226280
  const maxIndex = rows.reduce((m2, r) => Math.max(m2, r.entry_index), -1);
225798
226281
  const repairIndex = maxIndex + 1;
225799
- const repair = { type: "turn_end", timestamp: Date.now(), outcome: "server_restart" };
226282
+ const entries = [];
226283
+ for (const row of rows) {
226284
+ try {
226285
+ entries[row.entry_index] = JSON.parse(row.data);
226286
+ } catch {
226287
+ }
226288
+ }
226289
+ const disposition = resolveNotificationDisposition(
226290
+ findTurnOpeningUserEntry(entries, repairIndex)
226291
+ );
226292
+ const repair = {
226293
+ type: "turn_end",
226294
+ timestamp: Date.now(),
226295
+ outcome: "server_restart",
226296
+ notificationDisposition: disposition
226297
+ };
225800
226298
  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})`);
226299
+ const outbox = sessionMilestoneForTurnEnd({
226300
+ sessionId,
226301
+ projectId: dbSession.project_id,
226302
+ branch: dbSession.branch || null,
226303
+ entryIndex: repairIndex,
226304
+ outcome: "server_restart",
226305
+ disposition,
226306
+ createdAt: repair.timestamp
226307
+ });
226308
+ await this.storage.agentSessions.upsertTurnEndWithOutbox({
226309
+ sessionId,
226310
+ entryIndex: repairIndex,
226311
+ entryData: data,
226312
+ outbox
226313
+ });
226314
+ console.log(`[AgentSession] Repaired interrupted turn for ${sessionId} (server_restart turn_end at ${repairIndex}, disposition=${disposition})`);
225803
226315
  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
- }
226316
+ const project = await this.storage.projects.getById(dbSession.project_id);
226317
+ if (project?.path) {
226318
+ await recordTurnSnapshot(this.storage, sessionId, repairIndex, resolveWorktreePath(project.path, dbSession.branch));
225810
226319
  }
225811
226320
  } catch (error48) {
225812
226321
  console.warn(`[AgentSession] Turn snapshot lookup failed for ${sessionId}@${repairIndex}:`, error48);
@@ -225825,7 +226334,7 @@ ${details}`;
225825
226334
  let entries = await this.storage.agentSessions.getEntries(dbSession.id);
225826
226335
  if (entries.length === 0) continue;
225827
226336
  if (dbSession.status === "running") {
225828
- entries = await this.repairInterruptedTurn(dbSession.id, entries);
226337
+ entries = await this.repairInterruptedTurn(dbSession, entries);
225829
226338
  }
225830
226339
  const store = this.rebuildStoreFromRows(entries, dbSession.id);
225831
226340
  const permissionMode = dbSession.permission_mode === "plan" ? "plan" : "edit";
@@ -225842,13 +226351,15 @@ ${details}`;
225842
226351
  skipDb: false,
225843
226352
  permissionMode,
225844
226353
  agentType: dbSession.agent_type || "claude-code",
226354
+ model: dbSession.model ?? null,
225845
226355
  completion: new TurnCompletionLedger(),
225846
226356
  graceTimer: null,
225847
226357
  eventChain: Promise.resolve(),
225848
226358
  bgSpawnHintsThisTurn: 0,
225849
226359
  taskStartedThisTurn: 0,
225850
226360
  lastActiveAt: Date.now(),
225851
- turnOpenSince: null
226361
+ turnOpenSince: null,
226362
+ turnDisposition: null
225852
226363
  };
225853
226364
  this.sessions.set(dbSession.id, runningSession);
225854
226365
  await this.storage.agentSessions.updateStatusPreservingTimestamp(dbSession.id, "stopped");
@@ -225902,13 +226413,16 @@ ${details}`;
225902
226413
  const branch = source?.branch ?? (sourceRow.branch || null);
225903
226414
  const permissionMode = source?.permissionMode ?? (sourceRow?.permission_mode === "plan" ? "plan" : "edit");
225904
226415
  const agentType = agentTypeOverride ?? source?.agentType ?? (sourceRow?.agent_type || "claude-code");
226416
+ const sourceAgentType = source?.agentType ?? (sourceRow?.agent_type || "claude-code");
226417
+ const model = agentType === sourceAgentType ? source?.model ?? sourceRow?.model ?? null : null;
225905
226418
  const newId = opts.sessionId ?? randomUUID();
225906
226419
  await this.storage.agentSessions.create({
225907
226420
  id: newId,
225908
226421
  project_id: projectId,
225909
226422
  branch: branch ?? "",
225910
226423
  permission_mode: permissionMode,
225911
- agent_type: agentType
226424
+ agent_type: agentType,
226425
+ model
225912
226426
  });
225913
226427
  await this.storage.agentSessions.updateStatusPreservingTimestamp(newId, "stopped");
225914
226428
  for (const row of entryRows) {
@@ -225943,6 +226457,7 @@ ${details}`;
225943
226457
  skipDb: false,
225944
226458
  permissionMode,
225945
226459
  agentType,
226460
+ model,
225946
226461
  completion: new TurnCompletionLedger(),
225947
226462
  graceTimer: null,
225948
226463
  eventChain: Promise.resolve(),
@@ -225950,6 +226465,7 @@ ${details}`;
225950
226465
  taskStartedThisTurn: 0,
225951
226466
  lastActiveAt: Date.now(),
225952
226467
  turnOpenSince: null,
226468
+ turnDisposition: null,
225953
226469
  crossRemoteMcp: opts.crossRemoteMcp
225954
226470
  };
225955
226471
  this.sessions.set(newId, branched);
@@ -226296,9 +226812,397 @@ function runUpdatedEventFromRemoteFrame(parsed, sessionId, remoteInfo) {
226296
226812
  return { type: "workflow:run-updated", projectId, branch: run2.branch, run: run2 };
226297
226813
  }
226298
226814
 
226815
+ // src/routes/notification-outbox-routes.ts
226816
+ var import_fastify_plugin = __toESM(require_plugin2(), 1);
226817
+ var MAX_SESSIONS_PER_REQUEST = 100;
226818
+ var MAX_EVENTS_PER_SESSION = 100;
226819
+ function validateOutboxQuery(body) {
226820
+ const raw = body?.sessions;
226821
+ if (!Array.isArray(raw)) return { ok: false, error: "sessions must be an array" };
226822
+ if (raw.length === 0) return { ok: false, error: "sessions must not be empty" };
226823
+ if (raw.length > MAX_SESSIONS_PER_REQUEST) {
226824
+ return { ok: false, error: `sessions must contain at most ${MAX_SESSIONS_PER_REQUEST} entries` };
226825
+ }
226826
+ const seen = /* @__PURE__ */ new Set();
226827
+ const sessions = [];
226828
+ for (const entry of raw) {
226829
+ if (typeof entry !== "object" || entry === null) return { ok: false, error: "each session must be an object" };
226830
+ const { sessionId, after, headOnly } = entry;
226831
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
226832
+ return { ok: false, error: "sessionId must be a non-empty string" };
226833
+ }
226834
+ if (typeof after !== "number" || !Number.isInteger(after) || after < 0) {
226835
+ return { ok: false, error: "after must be a non-negative integer" };
226836
+ }
226837
+ if (headOnly !== void 0 && typeof headOnly !== "boolean") {
226838
+ return { ok: false, error: "headOnly must be a boolean" };
226839
+ }
226840
+ if (seen.has(sessionId)) return { ok: false, error: `duplicate sessionId: ${sessionId}` };
226841
+ seen.add(sessionId);
226842
+ sessions.push({ sessionId, after, ...headOnly ? { headOnly: true } : {} });
226843
+ }
226844
+ const rawLimit = body?.limitPerSession;
226845
+ if (rawLimit !== void 0) {
226846
+ if (typeof rawLimit !== "number" || !Number.isInteger(rawLimit) || rawLimit <= 0) {
226847
+ return { ok: false, error: "limitPerSession must be a positive integer" };
226848
+ }
226849
+ if (rawLimit > MAX_EVENTS_PER_SESSION) {
226850
+ return { ok: false, error: `limitPerSession must be at most ${MAX_EVENTS_PER_SESSION}` };
226851
+ }
226852
+ }
226853
+ return {
226854
+ ok: true,
226855
+ sessions,
226856
+ limit: typeof rawLimit === "number" ? rawLimit : MAX_EVENTS_PER_SESSION
226857
+ };
226858
+ }
226859
+ var routes = async (fastify2) => {
226860
+ fastify2.post("/api/notification-outbox/query", async (req, reply) => {
226861
+ const authResult = requireAuth(req, reply);
226862
+ if (authResult === null) return;
226863
+ const validated = validateOutboxQuery(req.body ?? {});
226864
+ if (!validated.ok) return reply.code(400).send({ error: validated.error });
226865
+ const sessions = [];
226866
+ for (const request of validated.sessions) {
226867
+ const headCursor = await fastify2.storage.notificationOutbox.headBySession(request.sessionId);
226868
+ if (request.headOnly) {
226869
+ sessions.push({
226870
+ sessionId: request.sessionId,
226871
+ events: [],
226872
+ headCursor,
226873
+ nextCursor: headCursor,
226874
+ hasMore: false
226875
+ });
226876
+ continue;
226877
+ }
226878
+ const events = await fastify2.storage.notificationOutbox.listBySessionAfter(
226879
+ request.sessionId,
226880
+ request.after,
226881
+ validated.limit
226882
+ );
226883
+ const nextCursor = events.length > 0 ? events[events.length - 1].seq : request.after;
226884
+ sessions.push({
226885
+ sessionId: request.sessionId,
226886
+ events,
226887
+ headCursor,
226888
+ nextCursor,
226889
+ hasMore: nextCursor < headCursor
226890
+ });
226891
+ }
226892
+ return reply.code(200).send({ sessions });
226893
+ });
226894
+ };
226895
+ var notification_outbox_routes_default = (0, import_fastify_plugin.default)(routes, { name: "notification-outbox-routes" });
226896
+
226897
+ // src/remote-notification-sync.ts
226898
+ var OUTBOX_QUERY_PATH = "/api/notification-outbox/query";
226899
+ var QUERY_TIMEOUT_MS = 15e3;
226900
+ var WATCH_WINDOW_MS = 30 * 60 * 1e3;
226901
+ var WATCH_EXTEND_THROTTLE_MS = 5 * 60 * 1e3;
226902
+ var MAX_TRACKED_SESSIONS = 5e3;
226903
+ var SYNC_INTERVAL_MS = 6e4;
226904
+ var MAX_PAGES_PER_SYNC = 50;
226905
+ function localWorkflowRunId(remoteServerId, projectId, remoteRunId) {
226906
+ return `remote-${remoteServerId}-${projectId}-${remoteRunId}`;
226907
+ }
226908
+ function localNotificationId(remoteServerId, outboxEventId) {
226909
+ return `remote:${remoteServerId}:${outboxEventId}`;
226910
+ }
226911
+ var RemoteNotificationSync = class {
226912
+ storage;
226913
+ notificationService;
226914
+ reverseConnectManager;
226915
+ proxy;
226916
+ timer = null;
226917
+ stopped = false;
226918
+ /** Serializes sweeps so a came-online sweep can't interleave with the tick. */
226919
+ chain = Promise.resolve();
226920
+ /**
226921
+ * Remote sessions this front currently believes are RUNNING. They are polled
226922
+ * regardless of their persisted watch window: an agent turn can easily run
226923
+ * longer than WATCH_WINDOW_MS while emitting nothing the bus can see, and a
226924
+ * mapping that lapsed mid-turn would not be polled when the turn finally
226925
+ * produces its milestone.
226926
+ */
226927
+ activeRemoteSessions = /* @__PURE__ */ new Set();
226928
+ /** sessionId → last activity-driven watch extension (see WATCH_EXTEND_THROTTLE_MS). */
226929
+ lastWatchExtend = /* @__PURE__ */ new Map();
226930
+ constructor(deps) {
226931
+ this.storage = deps.storage;
226932
+ this.notificationService = deps.notificationService;
226933
+ this.reverseConnectManager = deps.reverseConnectManager;
226934
+ this.proxy = deps.proxy ?? proxyToRemoteAuto;
226935
+ }
226936
+ start() {
226937
+ if (this.timer || this.stopped) return;
226938
+ this.timer = setInterval(() => {
226939
+ this.enqueue(() => this.syncAll({ includeExpired: false }));
226940
+ }, SYNC_INTERVAL_MS);
226941
+ this.timer.unref?.();
226942
+ }
226943
+ shutdown() {
226944
+ this.stopped = true;
226945
+ if (this.timer) clearInterval(this.timer);
226946
+ this.timer = null;
226947
+ this.activeRemoteSessions.clear();
226948
+ this.lastWatchExtend.clear();
226949
+ }
226950
+ /**
226951
+ * Watch live remote activity (design §Mapping initialization and polling).
226952
+ *
226953
+ * The bus carries the remote stream's bridged `session:status` /
226954
+ * `session:taskCompleted` frames plus this front's own emits for remote
226955
+ * sessions, so it is the one place that sees a remote session start and stop
226956
+ * without threading a callback through the WebSocket plumbing.
226957
+ */
226958
+ setEventBus(bus) {
226959
+ bus.subscribe((event) => this.observeActivity(event));
226960
+ }
226961
+ observeActivity(event) {
226962
+ const sessionId = event.sessionId;
226963
+ if (!sessionId || !sessionId.startsWith("remote-")) return;
226964
+ if (event.type === "session:status") {
226965
+ if (event.status === "running") this.activeRemoteSessions.add(sessionId);
226966
+ else this.activeRemoteSessions.delete(sessionId);
226967
+ } else if (event.type === "session:taskCompleted" || event.type === "session:finished") {
226968
+ this.activeRemoteSessions.delete(sessionId);
226969
+ }
226970
+ const now2 = Date.now();
226971
+ const last = this.lastWatchExtend.get(sessionId) ?? 0;
226972
+ if (now2 - last < WATCH_EXTEND_THROTTLE_MS) return;
226973
+ if (this.lastWatchExtend.size >= MAX_TRACKED_SESSIONS) this.pruneWatchBookkeeping(now2);
226974
+ this.lastWatchExtend.set(sessionId, now2);
226975
+ void this.extendWatch(sessionId);
226976
+ }
226977
+ /** Drop throttle entries older than a full window — they can only re-extend. */
226978
+ pruneWatchBookkeeping(now2) {
226979
+ for (const [sessionId, at] of this.lastWatchExtend) {
226980
+ if (now2 - at > WATCH_WINDOW_MS) this.lastWatchExtend.delete(sessionId);
226981
+ }
226982
+ }
226983
+ /** Fire-and-forget sweep, queued behind any in-flight one. */
226984
+ enqueue(work) {
226985
+ if (this.stopped) return;
226986
+ this.chain = this.chain.then(work).catch((err) => console.warn("[RemoteNotifications] sync failed:", err));
226987
+ }
226988
+ /** Extend a mapping's periodic-poll window (create / send / workflow start / live activity). */
226989
+ async extendWatch(localSessionId) {
226990
+ await this.storage.remoteSessionMappings.extendNotificationWatch(localSessionId, Date.now() + WATCH_WINDOW_MS).catch((err) => console.warn(`[RemoteNotifications] extendWatch(${localSessionId}) failed:`, err));
226991
+ }
226992
+ /**
226993
+ * Called before this front starts a new turn on a remote session.
226994
+ *
226995
+ * For a `from_now` mapping that has never been initialized, this records the
226996
+ * worker's current head FIRST — otherwise the turn we are about to start could
226997
+ * finish, be seen as "at or below the head we later record", and be suppressed
226998
+ * as history. Returns false when the baseline could not be recorded, in which
226999
+ * case the caller must NOT start the turn.
227000
+ */
227001
+ async prepareForNewTurn(localSessionId) {
227002
+ const mapping = await this.storage.remoteSessionMappings.getByLocal(localSessionId);
227003
+ if (!mapping) return true;
227004
+ await this.extendWatch(localSessionId);
227005
+ if (mapping.notification_sync_start !== "from_now") return true;
227006
+ const existing = await this.storage.notificationSyncCursors.get(
227007
+ mapping.remote_server_id,
227008
+ mapping.remote_session_id
227009
+ );
227010
+ if (existing !== void 0) return true;
227011
+ const target = await this.resolveTarget(mapping);
227012
+ if (!target) {
227013
+ console.warn(`[RemoteNotifications] no remote link for ${localSessionId}; skipping baseline`);
227014
+ return true;
227015
+ }
227016
+ const result = await this.query(target, [{ sessionId: mapping.remote_session_id, after: 0, headOnly: true }]);
227017
+ if (!result.ok) {
227018
+ console.warn(
227019
+ `[RemoteNotifications] baseline for ${localSessionId} failed (status ${result.status}); refusing to start the turn`
227020
+ );
227021
+ return false;
227022
+ }
227023
+ const sessions = this.parseSessions(result);
227024
+ const head = sessions.find((s3) => s3.sessionId === mapping.remote_session_id)?.headCursor;
227025
+ if (head === void 0) {
227026
+ console.warn(`[RemoteNotifications] baseline response for ${localSessionId} omitted the session`);
227027
+ return false;
227028
+ }
227029
+ await this.storage.notificationSyncCursors.initializeIfAbsent(
227030
+ mapping.remote_server_id,
227031
+ mapping.remote_session_id,
227032
+ head
227033
+ );
227034
+ return true;
227035
+ }
227036
+ /** Sweep every mapped remote server. */
227037
+ async syncAll(opts) {
227038
+ const mappings = await this.candidates(opts);
227039
+ for (const [serverId, group2] of groupByServer(mappings)) {
227040
+ await this.syncMappings(serverId, group2);
227041
+ }
227042
+ }
227043
+ /** Sweep one server — used when a reverse connection comes online. */
227044
+ async syncServer(remoteServerId, opts) {
227045
+ const mappings = (await this.candidates(opts)).filter((m2) => m2.remote_server_id === remoteServerId);
227046
+ if (mappings.length > 0) await this.syncMappings(remoteServerId, mappings);
227047
+ }
227048
+ /**
227049
+ * Mappings this sweep should poll: the persisted watch set, plus any session
227050
+ * currently believed to be running.
227051
+ *
227052
+ * The union is what keeps a turn longer than WATCH_WINDOW_MS covered. Its
227053
+ * window is extended when the turn starts, but a long turn can go silent for
227054
+ * hours; without this the mapping would lapse out of the periodic set and its
227055
+ * eventual milestone would wait for a server restart or a remote reconnect.
227056
+ */
227057
+ async candidates(opts) {
227058
+ const mappings = await this.storage.remoteSessionMappings.getNotificationSyncCandidates({
227059
+ now: Date.now(),
227060
+ includeExpired: opts.includeExpired
227061
+ });
227062
+ if (opts.includeExpired || this.activeRemoteSessions.size === 0) return mappings;
227063
+ const present = new Set(mappings.map((m2) => m2.local_session_id));
227064
+ for (const localSessionId of this.activeRemoteSessions) {
227065
+ if (present.has(localSessionId)) continue;
227066
+ const mapping = await this.storage.remoteSessionMappings.getByLocal(localSessionId);
227067
+ if (mapping) mappings.push(mapping);
227068
+ }
227069
+ return mappings;
227070
+ }
227071
+ async syncMappings(remoteServerId, mappings) {
227072
+ for (let i = 0; i < mappings.length; i += MAX_SESSIONS_PER_REQUEST) {
227073
+ const chunk = mappings.slice(i, i + MAX_SESSIONS_PER_REQUEST);
227074
+ await this.syncChunk(remoteServerId, chunk).catch(
227075
+ (err) => console.warn(`[RemoteNotifications] chunk sync for ${remoteServerId} failed:`, err)
227076
+ );
227077
+ }
227078
+ }
227079
+ async syncChunk(remoteServerId, chunk) {
227080
+ const target = await this.resolveTarget(chunk[0]);
227081
+ if (!target) {
227082
+ console.warn(`[RemoteNotifications] no remote link for server ${remoteServerId}; skipping ${chunk.length} mapping(s)`);
227083
+ return;
227084
+ }
227085
+ let pending = new Map(chunk.map((m2) => [m2.remote_session_id, m2]));
227086
+ for (let page = 0; page < MAX_PAGES_PER_SYNC && pending.size > 0; page++) {
227087
+ const cursors = await this.storage.notificationSyncCursors.getMany(
227088
+ remoteServerId,
227089
+ [...pending.keys()]
227090
+ );
227091
+ const requests = [...pending.values()].map((mapping) => {
227092
+ const cursor = cursors.get(mapping.remote_session_id);
227093
+ if (mapping.notification_sync_start === "from_now" && cursor === void 0) {
227094
+ return { sessionId: mapping.remote_session_id, after: 0, headOnly: true };
227095
+ }
227096
+ return { sessionId: mapping.remote_session_id, after: cursor ?? 0 };
227097
+ });
227098
+ const result = await this.query(target, requests);
227099
+ if (!result.ok) {
227100
+ console.warn(`[RemoteNotifications] outbox query to ${remoteServerId} failed (status ${result.status})`);
227101
+ return;
227102
+ }
227103
+ const byId = new Map(this.parseSessions(result).map((s3) => [s3.sessionId, s3]));
227104
+ const next = /* @__PURE__ */ new Map();
227105
+ for (const request of requests) {
227106
+ const mapping = pending.get(request.sessionId);
227107
+ const session = byId.get(request.sessionId);
227108
+ if (!session) continue;
227109
+ if ("headOnly" in request && request.headOnly) {
227110
+ await this.storage.notificationSyncCursors.initializeIfAbsent(
227111
+ remoteServerId,
227112
+ mapping.remote_session_id,
227113
+ session.headCursor
227114
+ );
227115
+ continue;
227116
+ }
227117
+ const imported = await this.importSession(remoteServerId, mapping, session);
227118
+ if (imported && session.hasMore) next.set(request.sessionId, mapping);
227119
+ }
227120
+ pending = next;
227121
+ }
227122
+ }
227123
+ /**
227124
+ * Import one session's page. Returns false when the batch was rejected, in
227125
+ * which case its cursor is untouched.
227126
+ */
227127
+ async importSession(remoteServerId, mapping, session) {
227128
+ for (const event of session.events) {
227129
+ if (event.session_id !== mapping.remote_session_id) {
227130
+ console.warn(
227131
+ `[RemoteNotifications] ${remoteServerId}: event ${event.id} claims session ${event.session_id}, expected ${mapping.remote_session_id}; rejecting batch`
227132
+ );
227133
+ return false;
227134
+ }
227135
+ }
227136
+ const ownerId = await this.storage.projects.getOwnerId(mapping.project_id);
227137
+ if (ownerId === void 0) {
227138
+ console.warn(`[RemoteNotifications] project ${mapping.project_id} not found; skipping ${mapping.local_session_id}`);
227139
+ return false;
227140
+ }
227141
+ for (const event of session.events) {
227142
+ const notification = await this.notificationService.buildNotification(event, {
227143
+ id: localNotificationId(remoteServerId, event.id),
227144
+ userId: ownerId || "local",
227145
+ projectId: mapping.project_id,
227146
+ sessionId: mapping.local_session_id,
227147
+ workflowRunId: event.workflow_run_id ? localWorkflowRunId(remoteServerId, mapping.project_id, event.workflow_run_id) : null
227148
+ });
227149
+ const { inserted } = await this.storage.notifications.importRemote({
227150
+ notification,
227151
+ remoteServerId,
227152
+ remoteSessionId: mapping.remote_session_id,
227153
+ seq: event.seq
227154
+ });
227155
+ if (inserted) this.notificationService.emitCreated(notification);
227156
+ }
227157
+ if (session.events.length > 0) await this.extendWatch(mapping.local_session_id);
227158
+ return true;
227159
+ }
227160
+ async query(target, sessions) {
227161
+ return this.proxy(
227162
+ target.serverId,
227163
+ target.url,
227164
+ target.apiKey,
227165
+ "POST",
227166
+ OUTBOX_QUERY_PATH,
227167
+ { sessions, limitPerSession: MAX_EVENTS_PER_SESSION },
227168
+ { timeoutMs: QUERY_TIMEOUT_MS, reverseConnectManager: this.reverseConnectManager }
227169
+ );
227170
+ }
227171
+ parseSessions(result) {
227172
+ const sessions = result.data?.sessions;
227173
+ return Array.isArray(sessions) ? sessions : [];
227174
+ }
227175
+ /**
227176
+ * URL/apiKey come from project_remotes — the authoritative source, same as the
227177
+ * remoteSessionMap hydration in shared-services. Reverse-connect rows have an
227178
+ * empty URL and are routed by server id instead.
227179
+ */
227180
+ async resolveTarget(mapping) {
227181
+ const remote = await this.storage.projectRemotes.getByProjectAndServer(
227182
+ mapping.project_id,
227183
+ mapping.remote_server_id
227184
+ );
227185
+ if (!remote) return null;
227186
+ return {
227187
+ serverId: mapping.remote_server_id,
227188
+ url: remote.server_url ?? "",
227189
+ apiKey: remote.server_api_key || ""
227190
+ };
227191
+ }
227192
+ };
227193
+ function groupByServer(mappings) {
227194
+ const groups = /* @__PURE__ */ new Map();
227195
+ for (const mapping of mappings) {
227196
+ const existing = groups.get(mapping.remote_server_id);
227197
+ if (existing) existing.push(mapping);
227198
+ else groups.set(mapping.remote_server_id, [mapping]);
227199
+ }
227200
+ return groups;
227201
+ }
227202
+
226299
227203
  // src/remote-agent-sessions.ts
226300
227204
  async function createRemoteAgentSession(deps, params) {
226301
- const { projectId, agentMode, remoteConfig, branch, permissionMode, agentType, force, userId } = params;
227205
+ const { projectId, agentMode, remoteConfig, branch, permissionMode, agentType, model, force, userId } = params;
226302
227206
  const remoteSessionId = randomUUID3();
226303
227207
  const localSessionId = `remote-${agentMode}-${projectId}-${remoteSessionId}`;
226304
227208
  const crossRemoteMcp = await mintCrossRemoteMcpConfig(
@@ -226320,7 +227224,7 @@ async function createRemoteAgentSession(deps, params) {
226320
227224
  remoteConfig.server_api_key || "",
226321
227225
  "POST",
226322
227226
  `/api/path/agent-sessions/new`,
226323
- { path: remoteConfig.remote_path, branch, permissionMode, agentType, force, sessionId: remoteSessionId, crossRemoteMcp },
227227
+ { path: remoteConfig.remote_path, branch, permissionMode, agentType, force, sessionId: remoteSessionId, crossRemoteMcp, model },
226324
227228
  { reverseConnectManager: deps.reverseConnectManager ?? void 0 }
226325
227229
  );
226326
227230
  if (!result.ok) {
@@ -226332,7 +227236,8 @@ async function createRemoteAgentSession(deps, params) {
226332
227236
  deps.remoteSessionMap.delete(localSessionId);
226333
227237
  return { ok: false, status: 409, data: { error: "Remote returned an unexpected session id; upgrade the remote" } };
226334
227238
  }
226335
- await deps.remoteSessionMappings.upsert(localSessionId, projectId, agentMode, remoteSessionId, branch ?? null);
227239
+ await deps.remoteSessionMappings.upsert(localSessionId, projectId, agentMode, remoteSessionId, branch ?? null, "from_start");
227240
+ await deps.remoteSessionMappings.extendNotificationWatch(localSessionId, Date.now() + WATCH_WINDOW_MS).catch((err) => console.warn("[RemoteSession] notification watch extend failed:", err));
226336
227241
  await deps.storage.searchCache.noteSessionCreated({
226337
227242
  localSessionId,
226338
227243
  projectId,
@@ -228975,6 +229880,15 @@ var WorkflowError = class extends Error {
228975
229880
  }
228976
229881
  code;
228977
229882
  };
229883
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "cancelled", "failed"]);
229884
+ var REVIEWER_TURN = {
229885
+ origin: "workflow",
229886
+ notificationDisposition: "milestone-managed"
229887
+ };
229888
+ var FEEDBACK_TURN = {
229889
+ origin: "workflow",
229890
+ notificationDisposition: "result"
229891
+ };
228978
229892
  var MAX_CONTEXT_CHARS = 2e3;
228979
229893
  var MAX_SELF_REPORT_CHARS = 4e3;
228980
229894
  var SELF_REPORT_MIN_CHARS = 80;
@@ -229170,10 +230084,57 @@ var WorkflowEngine = class {
229170
230084
  if (participant.runId === runId) this.participants.delete(sid);
229171
230085
  }
229172
230086
  }
230087
+ /**
230088
+ * The single place a run becomes `failed`. Goes through a guarded transition
230089
+ * out of the run's CURRENT status rather than an unconditional status write,
230090
+ * so the failure milestone can only be created by the caller that actually
230091
+ * performed the transition — a run that concurrently completed or was
230092
+ * cancelled fails the CAS and writes nothing.
230093
+ *
230094
+ * The milestone targets the participant the user should inspect: the reviewer
230095
+ * when one exists, otherwise the source session (a run can fail before its
230096
+ * reviewer is ever created).
230097
+ */
229173
230098
  async failRun(run2, error48) {
229174
- const failed = await this.storage.workflowRuns.update(run2.id, { status: "failed", error: error48 });
230099
+ if (TERMINAL_STATUSES.has(run2.status)) return;
230100
+ const from = run2.status;
230101
+ const ok = await this.storage.workflowRuns.transitionWithOutbox(
230102
+ run2.id,
230103
+ from,
230104
+ "failed",
230105
+ { error: error48 },
230106
+ {
230107
+ // The state the run failed OUT OF: two distinct failures of one run are
230108
+ // distinct attention milestones, while a retried transition out of the
230109
+ // same state is not.
230110
+ id: workflowFailedId(run2.id, from),
230111
+ kind: "workflow_failed",
230112
+ project_id: run2.project_id,
230113
+ branch: run2.branch,
230114
+ session_id: run2.reviewer_session_id ?? run2.source_session_id,
230115
+ workflow_run_id: run2.id,
230116
+ created_at: Date.now()
230117
+ }
230118
+ );
230119
+ if (!ok) return;
230120
+ const failed = await this.storage.workflowRuns.getById(run2.id);
229175
230121
  if (failed) this.untrackRun(failed);
230122
+ this.onMilestoneCreated?.();
230123
+ }
230124
+ /** Test seam for the failure path (see failRun). */
230125
+ async failRunForTest(runId, error48) {
230126
+ const run2 = await this.storage.workflowRuns.getById(runId);
230127
+ if (run2) await this.failRun(run2, error48);
229176
230128
  }
230129
+ /**
230130
+ * Injected by shared-services: nudges NotificationService to drain the local
230131
+ * outbox promptly. Latency only — correctness rests on the periodic/startup
230132
+ * drains, since this can't fire if the process dies right after the commit.
230133
+ */
230134
+ setMilestoneListener(listener) {
230135
+ this.onMilestoneCreated = listener;
230136
+ }
230137
+ onMilestoneCreated = null;
229177
230138
  /** Sync check used by ChatSessionManager before waking the commander model. */
229178
230139
  shouldSuppressAgentEvent(sessionId) {
229179
230140
  return this.participants.get(sessionId)?.role === "reviewer";
@@ -229299,7 +230260,7 @@ var WorkflowEngine = class {
229299
230260
  reviewFocus: opts.reviewFocus ?? null,
229300
230261
  target
229301
230262
  });
229302
- const sent = await this.agentOps.sendUserMessage(opts.reviewerSessionId, prompt, opts.project.path, void 0, { origin: "workflow" }).catch(() => false);
230263
+ const sent = await this.agentOps.sendUserMessage(opts.reviewerSessionId, prompt, opts.project.path, void 0, REVIEWER_TURN).catch(() => false);
229303
230264
  if (!sent) {
229304
230265
  await this.failRun(run2, "\u5411\u4E0A\u6B21 reviewer \u6295\u9012\u590D\u5BA1\u4EFB\u52A1\u5931\u8D25");
229305
230266
  throw new WorkflowError("send-failed", "\u5411\u4E0A\u6B21 reviewer \u6295\u9012\u590D\u5BA1\u4EFB\u52A1\u5931\u8D25");
@@ -229344,13 +230305,9 @@ var WorkflowEngine = class {
229344
230305
  target,
229345
230306
  scope
229346
230307
  });
229347
- const sent = await this.agentOps.sendUserMessage(reviewerId, prompt, opts.project.path, void 0, { origin: "workflow" });
230308
+ const sent = await this.agentOps.sendUserMessage(reviewerId, prompt, opts.project.path, void 0, REVIEWER_TURN);
229348
230309
  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);
230310
+ await this.failRun({ ...run2, reviewer_session_id: reviewerId }, "\u5411 reviewer \u6295\u9012\u4EFB\u52A1\u5931\u8D25");
229354
230311
  throw new WorkflowError("spawn-failed", "\u5411 reviewer \u6295\u9012\u4EFB\u52A1\u5931\u8D25");
229355
230312
  }
229356
230313
  const updated = await this.storage.workflowRuns.update(run2.id, { reviewer_session_id: reviewerId });
@@ -229359,11 +230316,7 @@ var WorkflowEngine = class {
229359
230316
  return updated;
229360
230317
  } catch (err) {
229361
230318
  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);
230319
+ await this.failRun(run2, `\u521B\u5EFA reviewer \u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`);
229367
230320
  throw new WorkflowError("spawn-failed", "\u521B\u5EFA reviewer session \u5931\u8D25");
229368
230321
  }
229369
230322
  } catch (err) {
@@ -229388,11 +230341,28 @@ var WorkflowEngine = class {
229388
230341
  }
229389
230342
  } catch {
229390
230343
  }
229391
- const ok = await this.storage.workflowRuns.transition(run2.id, "waiting_reviewer", "waiting_feedback", {
229392
- feedback_snapshot: feedback,
229393
- ...driftNote ? { error: driftNote } : {}
229394
- });
230344
+ const ok = await this.storage.workflowRuns.transitionWithOutbox(
230345
+ run2.id,
230346
+ "waiting_reviewer",
230347
+ "waiting_feedback",
230348
+ {
230349
+ feedback_snapshot: feedback,
230350
+ ...driftNote ? { error: driftNote } : {}
230351
+ },
230352
+ {
230353
+ id: reviewReadyId(run2.id),
230354
+ kind: "review_ready",
230355
+ project_id: run2.project_id,
230356
+ branch: run2.branch,
230357
+ // Target the reviewer: that's where the feedback and the
230358
+ // approve/discard controls are.
230359
+ session_id: event.sessionId,
230360
+ workflow_run_id: run2.id,
230361
+ created_at: Date.now()
230362
+ }
230363
+ );
229395
230364
  if (!ok) return;
230365
+ this.onMilestoneCreated?.();
229396
230366
  const updated = await this.storage.workflowRuns.getById(run2.id);
229397
230367
  if (updated) this.emitRunUpdated(updated);
229398
230368
  }
@@ -229409,7 +230379,7 @@ var WorkflowEngine = class {
229409
230379
  if (!claimed) throw new WorkflowError("bad-state", "run \u72B6\u6001\u5DF2\u53D8\u5316\uFF08\u53EF\u80FD\u5DF2\u88AB\u5904\u7406\uFF09");
229410
230380
  const feedback = editedPayload ?? run2.feedback_snapshot ?? "";
229411
230381
  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);
230382
+ const ok = await this.agentOps.sendUserMessage(run2.source_session_id, buildFeedbackMessage(feedback), project?.path ?? void 0, void 0, FEEDBACK_TURN).catch(() => false);
229413
230383
  if (!ok) {
229414
230384
  await this.storage.workflowRuns.transition(runId, "sending_feedback", "waiting_feedback", {
229415
230385
  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 +230400,7 @@ var WorkflowEngine = class {
229430
230400
  async cancelRun(runId, reason) {
229431
230401
  const run2 = await this.storage.workflowRuns.getById(runId);
229432
230402
  if (!run2) return void 0;
229433
- if (["completed", "cancelled", "failed"].includes(run2.status)) return run2;
230403
+ if (TERMINAL_STATUSES.has(run2.status)) return run2;
229434
230404
  const patch = reason ? { error: reason } : void 0;
229435
230405
  const cancelled = await this.storage.workflowRuns.transition(runId, "waiting_reviewer", "cancelled", patch) || await this.storage.workflowRuns.transition(runId, "waiting_feedback", "cancelled", patch);
229436
230406
  if (!cancelled) {
@@ -231244,6 +232214,154 @@ var SchedulerService = class {
231244
232214
  }
231245
232215
  };
231246
232216
 
232217
+ // src/notification-service.ts
232218
+ var TITLE_BY_KIND = {
232219
+ review_ready: "Review feedback is ready",
232220
+ session_result_ready: "Session result is ready",
232221
+ session_failed: "Session failed",
232222
+ workflow_failed: "Workflow needs attention"
232223
+ };
232224
+ var PLACEHOLDER_TITLES = /* @__PURE__ */ new Set(["New Session", "Generating title\u2026", "Generating title..."]);
232225
+ var LOCAL_CURSOR_KEY = "notification_local_cursor";
232226
+ var OUTBOX_PAGE_SIZE = 100;
232227
+ var READ_HISTORY_PER_USER = 500;
232228
+ var OUTBOX_RETENTION_DAYS = 90;
232229
+ var DRAIN_INTERVAL_MS = 3e4;
232230
+ var CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1e3;
232231
+ function notificationBody(opts) {
232232
+ const title = opts.sessionTitle?.trim();
232233
+ if (title && !PLACEHOLDER_TITLES.has(title)) return title;
232234
+ if (opts.branch) return opts.branch;
232235
+ return opts.projectName?.trim() || null;
232236
+ }
232237
+ function notificationTitle(kind) {
232238
+ return TITLE_BY_KIND[kind] ?? "Update";
232239
+ }
232240
+ var NotificationService = class {
232241
+ constructor(storage, eventBus) {
232242
+ this.storage = storage;
232243
+ this.eventBus = eventBus;
232244
+ }
232245
+ storage;
232246
+ eventBus;
232247
+ drainTimer = null;
232248
+ cleanupTimer = null;
232249
+ /** Serializes drains so a nudge can't interleave with the periodic tick. */
232250
+ drainChain = Promise.resolve();
232251
+ stopped = false;
232252
+ /**
232253
+ * Start the background timers. Unref'd: a pending drain must never be the
232254
+ * reason a CLI process refuses to exit.
232255
+ */
232256
+ start() {
232257
+ if (this.drainTimer || this.stopped) return;
232258
+ this.drainTimer = setInterval(() => this.requestDrain(), DRAIN_INTERVAL_MS);
232259
+ this.drainTimer.unref?.();
232260
+ this.cleanupTimer = setInterval(() => {
232261
+ void this.cleanup().catch((err) => console.warn("[Notifications] cleanup failed:", err));
232262
+ }, CLEANUP_INTERVAL_MS);
232263
+ this.cleanupTimer.unref?.();
232264
+ }
232265
+ shutdown() {
232266
+ this.stopped = true;
232267
+ if (this.drainTimer) clearInterval(this.drainTimer);
232268
+ if (this.cleanupTimer) clearInterval(this.cleanupTimer);
232269
+ this.drainTimer = null;
232270
+ this.cleanupTimer = null;
232271
+ }
232272
+ /**
232273
+ * Fire-and-forget nudge used right after a milestone lands, so the bell is
232274
+ * fast in the common case. Queued behind any in-flight drain.
232275
+ */
232276
+ requestDrain() {
232277
+ if (this.stopped) return;
232278
+ this.drainChain = this.drainChain.then(() => this.drainLocal()).catch((err) => console.warn("[Notifications] local drain failed:", err));
232279
+ }
232280
+ /**
232281
+ * Import every local outbox event after the durable cursor.
232282
+ *
232283
+ * The cursor advances per page AFTER its rows are inserted, so a crash
232284
+ * mid-drain replays the page — harmless, because insertion is idempotent on
232285
+ * the milestone id and `notification:created` is only emitted for rows this
232286
+ * call actually inserted.
232287
+ */
232288
+ async drainLocal() {
232289
+ for (; ; ) {
232290
+ const cursor = await this.readLocalCursor();
232291
+ const events = await this.storage.notificationOutbox.listAfter(cursor, OUTBOX_PAGE_SIZE);
232292
+ if (events.length === 0) return;
232293
+ for (const event of events) {
232294
+ await this.importLocalEvent(event);
232295
+ }
232296
+ await this.storage.settings.set(LOCAL_CURSOR_KEY, String(events[events.length - 1].seq));
232297
+ if (events.length < OUTBOX_PAGE_SIZE) return;
232298
+ }
232299
+ }
232300
+ async importLocalEvent(event) {
232301
+ const ownerId = await this.storage.projects.getOwnerId(event.project_id);
232302
+ if (ownerId === void 0) {
232303
+ console.warn(`[Notifications] dropping outbox event ${event.id}: project ${event.project_id} not found`);
232304
+ return;
232305
+ }
232306
+ const notification = await this.buildNotification(event, {
232307
+ id: event.id,
232308
+ // "" is the solo-mode column default; the browser routes read the
232309
+ // "local" sentinel, so normalize here rather than in every query.
232310
+ userId: ownerId || "local",
232311
+ sessionId: event.session_id,
232312
+ projectId: event.project_id,
232313
+ workflowRunId: event.workflow_run_id
232314
+ });
232315
+ if (await this.storage.notifications.insert(notification)) {
232316
+ this.eventBus.emit({ type: "notification:created", projectId: notification.project_id, notification });
232317
+ }
232318
+ }
232319
+ /**
232320
+ * Assemble the persisted row. `ids` is separate from the event because remote
232321
+ * imports substitute front-local identities (see RemoteNotificationSync) while
232322
+ * reusing this exact copy generation.
232323
+ */
232324
+ async buildNotification(event, ids) {
232325
+ const session = ids.sessionId ? await this.storage.agentSessions.getById(ids.sessionId) : void 0;
232326
+ const project = await this.storage.projects.getById(ids.projectId);
232327
+ return {
232328
+ id: ids.id,
232329
+ user_id: ids.userId,
232330
+ kind: event.kind,
232331
+ project_id: ids.projectId,
232332
+ branch: event.branch,
232333
+ session_id: ids.sessionId,
232334
+ workflow_run_id: ids.workflowRunId,
232335
+ title: notificationTitle(event.kind),
232336
+ body: notificationBody({
232337
+ sessionTitle: session?.title,
232338
+ branch: event.branch,
232339
+ projectName: project?.name
232340
+ }),
232341
+ created_at: event.created_at,
232342
+ read_at: null
232343
+ };
232344
+ }
232345
+ /** Emit for a row a remote import just inserted. */
232346
+ emitCreated(notification) {
232347
+ this.eventBus.emit({ type: "notification:created", projectId: notification.project_id, notification });
232348
+ }
232349
+ /** Retention: cap read history per user, prune aged worker outbox rows. */
232350
+ async cleanup() {
232351
+ await this.storage.notifications.cleanup(READ_HISTORY_PER_USER);
232352
+ await this.storage.notificationOutbox.pruneOlderThan(Date.now() - OUTBOX_RETENTION_DAYS * 864e5);
232353
+ }
232354
+ async readLocalCursor() {
232355
+ const raw = await this.storage.settings.get(LOCAL_CURSOR_KEY);
232356
+ const parsed = raw === void 0 ? 0 : Number(raw);
232357
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
232358
+ }
232359
+ /** Simulates a crash between inbox insertion and cursor commit. */
232360
+ async resetLocalCursorForTest() {
232361
+ await this.storage.settings.set(LOCAL_CURSOR_KEY, "0");
232362
+ }
232363
+ };
232364
+
231247
232365
  // src/plugins/shared-services.ts
231248
232366
  var sharedServices = async (fastify2, opts) => {
231249
232367
  const processManager = new ProcessManager(opts.storage);
@@ -231382,8 +232500,25 @@ var sharedServices = async (fastify2, opts) => {
231382
232500
  fastify2.decorate("browserManager", browserManager);
231383
232501
  fastify2.decorate("scheduler", scheduler);
231384
232502
  agentSessionManager.setEventBus(eventBus);
232503
+ const notificationService = new NotificationService(opts.storage, eventBus);
232504
+ fastify2.decorate("notificationService", notificationService);
232505
+ agentSessionManager.setMilestoneListener(() => notificationService.requestDrain());
232506
+ const remoteNotificationSync = new RemoteNotificationSync({
232507
+ storage: opts.storage,
232508
+ notificationService,
232509
+ reverseConnectManager
232510
+ });
232511
+ fastify2.decorate("remoteNotificationSync", remoteNotificationSync);
232512
+ remoteNotificationSync.setEventBus(eventBus);
232513
+ reverseConnectManager.setStatusChangeHandler((remoteServerId, status) => {
232514
+ if (status !== "online") return;
232515
+ remoteNotificationSync.enqueue(
232516
+ () => remoteNotificationSync.syncServer(remoteServerId, { includeExpired: true })
232517
+ );
232518
+ });
231385
232519
  const workflowEngine = new WorkflowEngine(opts.storage, agentSessionManager);
231386
232520
  workflowEngine.setEventBus(eventBus);
232521
+ workflowEngine.setMilestoneListener(() => notificationService.requestDrain());
231387
232522
  await workflowEngine.init();
231388
232523
  fastify2.decorate("workflowEngine", workflowEngine);
231389
232524
  chatSessionManager.setWorkflowEngine(workflowEngine);
@@ -231393,6 +232528,10 @@ var sharedServices = async (fastify2, opts) => {
231393
232528
  processManager.setEventBus(eventBus);
231394
232529
  scheduler.setEventBus(eventBus);
231395
232530
  await scheduler.start();
232531
+ notificationService.start();
232532
+ notificationService.requestDrain();
232533
+ remoteNotificationSync.start();
232534
+ remoteNotificationSync.enqueue(() => remoteNotificationSync.syncAll({ includeExpired: true }));
231396
232535
  void (async () => {
231397
232536
  const savedRows = await opts.storage.remoteExecutorProcesses.getRunning();
231398
232537
  const directUrlRows = savedRows.filter((r) => r.remote_url);
@@ -231408,6 +232547,8 @@ var sharedServices = async (fastify2, opts) => {
231408
232547
  });
231409
232548
  fastify2.addHook("onClose", async () => {
231410
232549
  scheduler.shutdown();
232550
+ notificationService.shutdown();
232551
+ remoteNotificationSync.shutdown();
231411
232552
  agentSessionManager.shutdown();
231412
232553
  processManager.shutdown();
231413
232554
  remotePatchCache.shutdown();
@@ -231416,10 +232557,10 @@ var sharedServices = async (fastify2, opts) => {
231416
232557
  await browserManager.shutdown();
231417
232558
  });
231418
232559
  };
231419
- var shared_services_default = (0, import_fastify_plugin.default)(sharedServices, { name: "shared-services" });
232560
+ var shared_services_default = (0, import_fastify_plugin2.default)(sharedServices, { name: "shared-services" });
231420
232561
 
231421
232562
  // src/routes/project-routes.ts
231422
- var import_fastify_plugin2 = __toESM(require_plugin2(), 1);
232563
+ var import_fastify_plugin3 = __toESM(require_plugin2(), 1);
231423
232564
  import path8 from "path";
231424
232565
  import { randomUUID as randomUUID9 } from "crypto";
231425
232566
  import { exec as exec2 } from "child_process";
@@ -231472,7 +232613,7 @@ function sanitizeProject(project) {
231472
232613
  const { remote_api_key, ...safe } = project;
231473
232614
  return { ...safe, has_remote_api_key: !!remote_api_key };
231474
232615
  }
231475
- var routes = async (fastify2) => {
232616
+ var routes2 = async (fastify2) => {
231476
232617
  fastify2.get("/api/projects", async (req, reply) => {
231477
232618
  const userId = requireAuth(req, reply);
231478
232619
  if (userId === null) return;
@@ -231734,12 +232875,12 @@ var routes = async (fastify2) => {
231734
232875
  }
231735
232876
  });
231736
232877
  };
231737
- var project_routes_default = (0, import_fastify_plugin2.default)(routes, { name: "project-routes" });
232878
+ var project_routes_default = (0, import_fastify_plugin3.default)(routes2, { name: "project-routes" });
231738
232879
 
231739
232880
  // src/routes/remote-routes.ts
231740
- var import_fastify_plugin3 = __toESM(require_plugin2(), 1);
232881
+ var import_fastify_plugin4 = __toESM(require_plugin2(), 1);
231741
232882
  import { randomUUID as randomUUID10 } from "crypto";
231742
- var routes2 = async (fastify2) => {
232883
+ var routes3 = async (fastify2) => {
231743
232884
  fastify2.post("/api/remote/test-connection", async (req, reply) => {
231744
232885
  const { url: url2, apiKey } = req.body;
231745
232886
  if (!url2 || !apiKey) {
@@ -231791,17 +232932,17 @@ var routes2 = async (fastify2) => {
231791
232932
  return reply.code(201).send({ project: safeProject });
231792
232933
  });
231793
232934
  };
231794
- var remote_routes_default = (0, import_fastify_plugin3.default)(routes2, { name: "remote-routes" });
232935
+ var remote_routes_default = (0, import_fastify_plugin4.default)(routes3, { name: "remote-routes" });
231795
232936
 
231796
232937
  // src/routes/remote-server-routes.ts
231797
- var import_fastify_plugin4 = __toESM(require_plugin2(), 1);
232938
+ var import_fastify_plugin5 = __toESM(require_plugin2(), 1);
231798
232939
  function sanitizeServer(server) {
231799
232940
  const { api_key: _3, connect_token: _t, ...safe } = server;
231800
232941
  return safe;
231801
232942
  }
231802
232943
  var CROSS_REMOTE_ACCESS_VALUES = ["off", "read", "exec"];
231803
232944
  var isCrossRemoteAccess = (value) => typeof value === "string" && CROSS_REMOTE_ACCESS_VALUES.includes(value);
231804
- var routes3 = async (fastify2) => {
232945
+ var routes4 = async (fastify2) => {
231805
232946
  fastify2.get("/api/remote-servers", async (request, reply) => {
231806
232947
  const userId = requireAuth(request, reply);
231807
232948
  if (userId === null) return;
@@ -231986,15 +233127,15 @@ var routes3 = async (fastify2) => {
231986
233127
  }
231987
233128
  );
231988
233129
  };
231989
- var remote_server_routes_default = (0, import_fastify_plugin4.default)(routes3, { name: "remote-server-routes" });
233130
+ var remote_server_routes_default = (0, import_fastify_plugin5.default)(routes4, { name: "remote-server-routes" });
231990
233131
 
231991
233132
  // src/routes/project-remote-routes.ts
231992
- var import_fastify_plugin5 = __toESM(require_plugin2(), 1);
233133
+ var import_fastify_plugin6 = __toESM(require_plugin2(), 1);
231993
233134
  function sanitizeProjectRemote(pr) {
231994
233135
  const { server_api_key: _3, ...safe } = pr;
231995
233136
  return safe;
231996
233137
  }
231997
- var routes4 = async (fastify2) => {
233138
+ var routes5 = async (fastify2) => {
231998
233139
  fastify2.get(
231999
233140
  "/api/projects/:id/remotes",
232000
233141
  async (request, reply) => {
@@ -232088,12 +233229,12 @@ var routes4 = async (fastify2) => {
232088
233229
  }
232089
233230
  );
232090
233231
  };
232091
- var project_remote_routes_default = (0, import_fastify_plugin5.default)(routes4, { name: "project-remote-routes" });
233232
+ var project_remote_routes_default = (0, import_fastify_plugin6.default)(routes5, { name: "project-remote-routes" });
232092
233233
 
232093
233234
  // src/routes/executor-group-routes.ts
232094
- var import_fastify_plugin6 = __toESM(require_plugin2(), 1);
233235
+ var import_fastify_plugin7 = __toESM(require_plugin2(), 1);
232095
233236
  import { randomUUID as randomUUID11 } from "crypto";
232096
- var routes5 = async (fastify2) => {
233237
+ var routes6 = async (fastify2) => {
232097
233238
  fastify2.get(
232098
233239
  "/api/projects/:projectId/executor-groups",
232099
233240
  async (req, reply) => {
@@ -232164,17 +233305,17 @@ var routes5 = async (fastify2) => {
232164
233305
  }
232165
233306
  );
232166
233307
  };
232167
- var executor_group_routes_default = (0, import_fastify_plugin6.default)(routes5, { name: "executor-group-routes" });
233308
+ var executor_group_routes_default = (0, import_fastify_plugin7.default)(routes6, { name: "executor-group-routes" });
232168
233309
 
232169
233310
  // src/routes/executor-routes.ts
232170
- var import_fastify_plugin7 = __toESM(require_plugin2(), 1);
233311
+ var import_fastify_plugin8 = __toESM(require_plugin2(), 1);
232171
233312
  import { randomUUID as randomUUID12 } from "crypto";
232172
233313
  function normalizeSqlTimestamp(value) {
232173
233314
  if (!value) return null;
232174
233315
  if (value.includes("T") && /(Z|[+-]\d{2}:?\d{2})$/.test(value)) return value;
232175
233316
  return value.replace(" ", "T") + "Z";
232176
233317
  }
232177
- var routes6 = async (fastify2) => {
233318
+ var routes7 = async (fastify2) => {
232178
233319
  fastify2.get(
232179
233320
  "/api/projects/:projectId/executors",
232180
233321
  async (req, reply) => {
@@ -232313,13 +233454,13 @@ var routes6 = async (fastify2) => {
232313
233454
  return reply.code(200).send({ success: true });
232314
233455
  });
232315
233456
  };
232316
- var executor_routes_default = (0, import_fastify_plugin7.default)(routes6, { name: "executor-routes" });
233457
+ var executor_routes_default = (0, import_fastify_plugin8.default)(routes7, { name: "executor-routes" });
232317
233458
 
232318
233459
  // src/routes/process-routes.ts
232319
- var import_fastify_plugin8 = __toESM(require_plugin2(), 1);
233460
+ var import_fastify_plugin9 = __toESM(require_plugin2(), 1);
232320
233461
  import path9 from "path";
232321
233462
  import { randomUUID as randomUUID13 } from "crypto";
232322
- var routes7 = async (fastify2) => {
233463
+ var routes8 = async (fastify2) => {
232323
233464
  fastify2.post("/api/path/execute", async (req, reply) => {
232324
233465
  const { path: projectPath, command, executor_type, prompt_provider, cwd, branch, pty: pty2 } = req.body;
232325
233466
  if (!projectPath || !command) {
@@ -232529,10 +233670,10 @@ var routes7 = async (fastify2) => {
232529
233670
  return reply.code(200).send({ processes });
232530
233671
  });
232531
233672
  };
232532
- var process_routes_default = (0, import_fastify_plugin8.default)(routes7, { name: "process-routes" });
233673
+ var process_routes_default = (0, import_fastify_plugin9.default)(routes8, { name: "process-routes" });
232533
233674
 
232534
233675
  // src/routes/worktree-routes.ts
232535
- var import_fastify_plugin9 = __toESM(require_plugin2(), 1);
233676
+ var import_fastify_plugin10 = __toESM(require_plugin2(), 1);
232536
233677
  import { mkdir as mkdir3 } from "fs/promises";
232537
233678
  async function getAllRemoteConfigs(fastify2, project) {
232538
233679
  const remotes = await fastify2.storage.projectRemotes.getByProject(project.id);
@@ -232558,7 +233699,7 @@ async function getRemoteConfig(fastify2, project) {
232558
233699
  const all = await getAllRemoteConfigs(fastify2, project);
232559
233700
  return all.length > 0 ? all[0] : null;
232560
233701
  }
232561
- var routes8 = async (fastify2) => {
233702
+ var routes9 = async (fastify2) => {
232562
233703
  fastify2.get("/api/path/branches", async (req, reply) => {
232563
233704
  const projectPath = req.query.path;
232564
233705
  if (!projectPath) {
@@ -233096,10 +234237,10 @@ var routes8 = async (fastify2) => {
233096
234237
  });
233097
234238
  });
233098
234239
  };
233099
- var worktree_routes_default = (0, import_fastify_plugin9.default)(routes8, { name: "worktree-routes" });
234240
+ var worktree_routes_default = (0, import_fastify_plugin10.default)(routes9, { name: "worktree-routes" });
233100
234241
 
233101
234242
  // src/routes/diff-routes.ts
233102
- var import_fastify_plugin10 = __toESM(require_plugin2(), 1);
234243
+ var import_fastify_plugin11 = __toESM(require_plugin2(), 1);
233103
234244
  import path10 from "path";
233104
234245
  import { readFileSync as readFileSync4 } from "fs";
233105
234246
  import { execFileSync as execFileSync5 } from "child_process";
@@ -233419,7 +234560,7 @@ async function getUntrackedFiles(cwd) {
233419
234560
  }
233420
234561
  return files;
233421
234562
  }
233422
- var routes9 = async (fastify2) => {
234563
+ var routes10 = async (fastify2) => {
233423
234564
  fastify2.get("/api/path/diff", async (req, reply) => {
233424
234565
  const projectPath = req.query.path;
233425
234566
  if (!projectPath) {
@@ -233614,10 +234755,10 @@ var routes9 = async (fastify2) => {
233614
234755
  }
233615
234756
  });
233616
234757
  };
233617
- var diff_routes_default = (0, import_fastify_plugin10.default)(routes9, { name: "diff-routes" });
234758
+ var diff_routes_default = (0, import_fastify_plugin11.default)(routes10, { name: "diff-routes" });
233618
234759
 
233619
234760
  // src/routes/file-routes.ts
233620
- var import_fastify_plugin11 = __toESM(require_plugin2(), 1);
234761
+ var import_fastify_plugin12 = __toESM(require_plugin2(), 1);
233621
234762
  import path11 from "path";
233622
234763
  import fs2 from "fs/promises";
233623
234764
  import { createReadStream, constants as fsConstants } from "fs";
@@ -233853,7 +234994,7 @@ async function searchSymbol(basePath33, symbol26) {
233853
234994
  }
233854
234995
  return { hits, truncated };
233855
234996
  }
233856
- var routes10 = async (fastify2) => {
234997
+ var routes11 = async (fastify2) => {
233857
234998
  fastify2.get("/api/path/browse", async (req, reply) => {
233858
234999
  const projectPath = req.query.path;
233859
235000
  if (!projectPath) {
@@ -234447,10 +235588,10 @@ var routes10 = async (fastify2) => {
234447
235588
  }
234448
235589
  });
234449
235590
  };
234450
- var file_routes_default = (0, import_fastify_plugin11.default)(routes10, { name: "file-routes" });
235591
+ var file_routes_default = (0, import_fastify_plugin12.default)(routes11, { name: "file-routes" });
234451
235592
 
234452
235593
  // src/routes/agent-session-routes.ts
234453
- var import_fastify_plugin12 = __toESM(require_plugin2(), 1);
235594
+ var import_fastify_plugin13 = __toESM(require_plugin2(), 1);
234454
235595
 
234455
235596
  // src/utils/paste-file.ts
234456
235597
  import { chmod, mkdir as mkdir4, open } from "node:fs/promises";
@@ -234482,6 +235623,14 @@ function resolveUserId(authResult) {
234482
235623
 
234483
235624
  // src/routes/agent-session-routes.ts
234484
235625
  import { randomUUID as randomUUID15 } from "crypto";
235626
+
235627
+ // src/protocol/model-suggestions.ts
235628
+ var MODEL_SUGGESTIONS = {
235629
+ "claude-code": ["opus", "sonnet", "haiku"],
235630
+ codex: ["gpt-5.6-sol", "gpt-5.6-codex", "o3"]
235631
+ };
235632
+
235633
+ // src/routes/agent-session-routes.ts
234485
235634
  async function resolveProjectPath(projectId, storage) {
234486
235635
  if (projectId.startsWith("path:")) {
234487
235636
  return projectId.slice(5);
@@ -234498,7 +235647,7 @@ function messageTextLength(content) {
234498
235647
  }
234499
235648
  return n;
234500
235649
  }
234501
- var routes11 = async (fastify2) => {
235650
+ var routes12 = async (fastify2) => {
234502
235651
  function proxyAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body) {
234503
235652
  return proxyToRemoteAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body, {
234504
235653
  reverseConnectManager: fastify2.reverseConnectManager
@@ -234545,6 +235694,7 @@ var routes11 = async (fastify2) => {
234545
235694
  status: session?.status || "stopped",
234546
235695
  permissionMode: session?.permissionMode || "edit",
234547
235696
  agentType: session?.agentType || "claude-code",
235697
+ model: session?.model ?? null,
234548
235698
  title: dbRow?.title ?? null
234549
235699
  },
234550
235700
  messages
@@ -234555,15 +235705,23 @@ var routes11 = async (fastify2) => {
234555
235705
  fastify2.agentSessionManager.emitSessionTitle(projectId, branch, sessionId, title);
234556
235706
  }
234557
235707
  fastify2.get("/api/agent-providers", async (_req, reply) => {
234558
- const providers2 = getAllProviders().map((provider) => ({
234559
- type: provider.getAgentType(),
234560
- displayName: provider.getDisplayName(),
234561
- available: provider.isAvailable?.() ?? provider.detectBinary() !== null
234562
- }));
235708
+ const providers2 = getAllProviders().map((provider) => {
235709
+ const type = provider.getAgentType();
235710
+ return {
235711
+ type,
235712
+ displayName: provider.getDisplayName(),
235713
+ available: provider.isAvailable?.() ?? provider.detectBinary() !== null,
235714
+ // Suggestions only. The picker also accepts free text, and nothing
235715
+ // validates against this list — the session's real capabilities depend
235716
+ // on the CLI version and account tier of whichever machine spawns it,
235717
+ // which this server cannot know.
235718
+ models: MODEL_SUGGESTIONS[type] ?? []
235719
+ };
235720
+ });
234563
235721
  return reply.code(200).send({ providers: providers2 });
234564
235722
  });
234565
235723
  fastify2.post("/api/path/agent-sessions", async (req, reply) => {
234566
- const { path: projectPath, branch, permissionMode, agentType, force } = req.body;
235724
+ const { path: projectPath, branch, permissionMode, agentType, force, model } = req.body;
234567
235725
  if (!projectPath) {
234568
235726
  return reply.code(400).send({ error: "Path is required" });
234569
235727
  }
@@ -234611,6 +235769,7 @@ var routes11 = async (fastify2) => {
234611
235769
  status: effectiveStatus,
234612
235770
  permissionMode: session?.permissionMode || "edit",
234613
235771
  agentType: session?.agentType || "claude-code",
235772
+ model: session?.model ?? null,
234614
235773
  processAlive: session ? fastify2.agentSessionManager.getSessionProcessAlive(sessionId) : false
234615
235774
  },
234616
235775
  messages
@@ -234655,7 +235814,7 @@ var routes11 = async (fastify2) => {
234655
235814
  }
234656
235815
  );
234657
235816
  fastify2.post("/api/path/agent-sessions/new", async (req, reply) => {
234658
- const { path: projectPath, branch, permissionMode, agentType, force, sessionId, crossRemoteMcp } = req.body;
235817
+ const { path: projectPath, branch, permissionMode, agentType, force, sessionId, crossRemoteMcp, model } = req.body;
234659
235818
  if (!projectPath) {
234660
235819
  return reply.code(400).send({ error: "Path is required" });
234661
235820
  }
@@ -234683,7 +235842,7 @@ var routes11 = async (fastify2) => {
234683
235842
  agentType || "claude-code",
234684
235843
  false,
234685
235844
  force === true,
234686
- { sessionId, crossRemoteMcp }
235845
+ { sessionId, crossRemoteMcp, model }
234687
235846
  );
234688
235847
  const session = fastify2.agentSessionManager.getSession(createdSessionId);
234689
235848
  return reply.code(200).send({
@@ -234694,6 +235853,7 @@ var routes11 = async (fastify2) => {
234694
235853
  status: session?.status || "running",
234695
235854
  permissionMode: session?.permissionMode || "edit",
234696
235855
  agentType: session?.agentType || "claude-code",
235856
+ model: session?.model ?? null,
234697
235857
  processAlive: session ? fastify2.agentSessionManager.getSessionProcessAlive(session.id) : false
234698
235858
  },
234699
235859
  messages: []
@@ -234756,7 +235916,8 @@ var routes11 = async (fastify2) => {
234756
235916
  project.id,
234757
235917
  project.agent_mode,
234758
235918
  s3.id,
234759
- s3.branch ?? null
235919
+ s3.branch ?? null,
235920
+ "from_now"
234760
235921
  );
234761
235922
  return { ...s3, id: localSessionId, processAlive: s3.processAlive ?? false, entry_count: s3.entry_count ?? 0 };
234762
235923
  }));
@@ -234795,7 +235956,7 @@ var routes11 = async (fastify2) => {
234795
235956
  if (!project) {
234796
235957
  return reply.code(404).send({ error: "Project not found" });
234797
235958
  }
234798
- const { branch, permissionMode, agentType } = req.body;
235959
+ const { branch, permissionMode, agentType, model } = req.body;
234799
235960
  let agentMode = project.agent_mode;
234800
235961
  let useRemoteAgent = agentMode !== "local";
234801
235962
  if (useRemoteAgent && agentMode === "remote") {
@@ -234830,7 +235991,7 @@ var routes11 = async (fastify2) => {
234830
235991
  remoteConfig.server_api_key || "",
234831
235992
  "POST",
234832
235993
  `/api/path/agent-sessions`,
234833
- { path: remoteConfig.remote_path, branch, permissionMode, agentType }
235994
+ { path: remoteConfig.remote_path, branch, permissionMode, agentType, model }
234834
235995
  );
234835
235996
  console.log(`[API] Remote proxy result: ok=${result.ok}, status=${result.status}, data=${JSON.stringify(result.data).substring(0, 500)}`);
234836
235997
  if (result.ok) {
@@ -234851,7 +236012,8 @@ var routes11 = async (fastify2) => {
234851
236012
  project.id,
234852
236013
  agentMode,
234853
236014
  remoteData.session.id,
234854
- branch ?? null
236015
+ branch ?? null,
236016
+ "from_now"
234855
236017
  );
234856
236018
  if (remoteData.messages && remoteData.messages.length > 0) {
234857
236019
  const cacheEntry = fastify2.remotePatchCache.getOrCreate(localSessionId);
@@ -234907,6 +236069,7 @@ var routes11 = async (fastify2) => {
234907
236069
  status: effectiveStatus,
234908
236070
  permissionMode: session?.permissionMode || "edit",
234909
236071
  agentType: session?.agentType || "claude-code",
236072
+ model: session?.model ?? null,
234910
236073
  processAlive: session ? fastify2.agentSessionManager.getSessionProcessAlive(sessionId) : false
234911
236074
  },
234912
236075
  messages
@@ -234923,7 +236086,7 @@ var routes11 = async (fastify2) => {
234923
236086
  if (!project) {
234924
236087
  return reply.code(404).send({ error: "Project not found" });
234925
236088
  }
234926
- const { branch, permissionMode, agentType, force } = req.body;
236089
+ const { branch, permissionMode, agentType, force, model } = req.body;
234927
236090
  const agentMode = project.agent_mode;
234928
236091
  const useRemoteAgent = agentMode !== "local";
234929
236092
  if (useRemoteAgent) {
@@ -234949,7 +236112,8 @@ var routes11 = async (fastify2) => {
234949
236112
  permissionMode: permissionMode || "edit",
234950
236113
  agentType,
234951
236114
  force: force === true,
234952
- userId
236115
+ userId,
236116
+ model
234953
236117
  }
234954
236118
  );
234955
236119
  if (created.ok) {
@@ -234982,7 +236146,7 @@ var routes11 = async (fastify2) => {
234982
236146
  agentType || "claude-code",
234983
236147
  false,
234984
236148
  force === true,
234985
- { sessionId: preSessionId, crossRemoteMcp }
236149
+ { sessionId: preSessionId, crossRemoteMcp, model }
234986
236150
  );
234987
236151
  const session = fastify2.agentSessionManager.getSession(sessionId);
234988
236152
  return reply.code(200).send({
@@ -234993,6 +236157,7 @@ var routes11 = async (fastify2) => {
234993
236157
  status: session?.status || "running",
234994
236158
  permissionMode: session?.permissionMode || "edit",
234995
236159
  agentType: session?.agentType || "claude-code",
236160
+ model: session?.model ?? null,
234996
236161
  processAlive: session ? fastify2.agentSessionManager.getSessionProcessAlive(sessionId) : false
234997
236162
  },
234998
236163
  messages: []
@@ -235049,6 +236214,7 @@ var routes11 = async (fastify2) => {
235049
236214
  status: session.status,
235050
236215
  permissionMode: session.permissionMode,
235051
236216
  agentType: session.agentType || "claude-code",
236217
+ model: session.model ?? null,
235052
236218
  processAlive: fastify2.agentSessionManager.getSessionProcessAlive(session.id)
235053
236219
  },
235054
236220
  messages
@@ -235078,6 +236244,12 @@ var routes11 = async (fastify2) => {
235078
236244
  console.log(`[API] /message 404: remote session not found. Known keys: [${[...fastify2.remoteSessionMap.keys()].join(", ")}]`);
235079
236245
  return reply.code(404).send({ error: "Remote session not found" });
235080
236246
  }
236247
+ if (!await fastify2.remoteNotificationSync.prepareForNewTurn(req.params.sessionId)) {
236248
+ return reply.code(502).send({
236249
+ error: "Could not reach the remote server to prepare notification delivery",
236250
+ errorCode: "notification_baseline_failed"
236251
+ });
236252
+ }
235081
236253
  const result = await proxyAuto(
235082
236254
  remoteInfo.remoteServerId,
235083
236255
  remoteInfo.remoteUrl,
@@ -235332,7 +236504,8 @@ var routes11 = async (fastify2) => {
235332
236504
  projectId,
235333
236505
  remoteInfo.remoteServerId,
235334
236506
  newRemoteSessionId,
235335
- remoteInfo.branch ?? null
236507
+ remoteInfo.branch ?? null,
236508
+ "from_start"
235336
236509
  );
235337
236510
  await fastify2.storage.remoteSessionMappings.markTitleResolved(localSessionId);
235338
236511
  fastify2.agentSessionManager.markTitleResolved(localSessionId);
@@ -235627,10 +236800,10 @@ var routes11 = async (fastify2) => {
235627
236800
  return reply.code(200).send({ success: true, favorited });
235628
236801
  });
235629
236802
  };
235630
- var agent_session_routes_default = (0, import_fastify_plugin12.default)(routes11, { name: "agent-session-routes" });
236803
+ var agent_session_routes_default = (0, import_fastify_plugin13.default)(routes12, { name: "agent-session-routes" });
235631
236804
 
235632
236805
  // src/routes/branch-activity-routes.ts
235633
- var import_fastify_plugin13 = __toESM(require_plugin2(), 1);
236806
+ var import_fastify_plugin14 = __toESM(require_plugin2(), 1);
235634
236807
  function toResponse(map2) {
235635
236808
  return {
235636
236809
  branches: Array.from(map2.entries()).map(([branch, state]) => ({
@@ -235645,7 +236818,7 @@ function fromResponse(resp) {
235645
236818
  resp.branches.map((b2) => [b2.branch ?? "", { activity: b2.activity, since: b2.since }])
235646
236819
  );
235647
236820
  }
235648
- var routes12 = async (fastify2) => {
236821
+ var routes13 = async (fastify2) => {
235649
236822
  function proxyAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body) {
235650
236823
  return proxyToRemoteAuto(remoteServerId, remoteUrl, remoteApiKey, method, apiPath, body, {
235651
236824
  reverseConnectManager: fastify2.reverseConnectManager
@@ -235712,10 +236885,10 @@ var routes12 = async (fastify2) => {
235712
236885
  }
235713
236886
  );
235714
236887
  };
235715
- var branch_activity_routes_default = (0, import_fastify_plugin13.default)(routes12, { name: "branch-activity-routes" });
236888
+ var branch_activity_routes_default = (0, import_fastify_plugin14.default)(routes13, { name: "branch-activity-routes" });
235716
236889
 
235717
236890
  // src/routes/merge-status-routes.ts
235718
- var import_fastify_plugin14 = __toESM(require_plugin2(), 1);
236891
+ var import_fastify_plugin15 = __toESM(require_plugin2(), 1);
235719
236892
  function resolveTargets(comparisons, storedTargets) {
235720
236893
  const sources = [];
235721
236894
  const effective = comparisons.map((comparison) => {
@@ -235849,7 +237022,7 @@ function sendComputed(reply, repoPath, comparisons, repository) {
235849
237022
  return reply.code(statusCode).send({ error: message });
235850
237023
  }
235851
237024
  }
235852
- var routes13 = async (fastify2) => {
237025
+ var routes14 = async (fastify2) => {
235853
237026
  fastify2.post(
235854
237027
  "/api/path/branches/merge-status",
235855
237028
  async (req, reply) => {
@@ -235973,11 +237146,11 @@ var routes13 = async (fastify2) => {
235973
237146
  }
235974
237147
  );
235975
237148
  };
235976
- var merge_status_routes_default = (0, import_fastify_plugin14.default)(routes13, { name: "merge-status-routes" });
237149
+ var merge_status_routes_default = (0, import_fastify_plugin15.default)(routes14, { name: "merge-status-routes" });
235977
237150
 
235978
237151
  // src/routes/chat-session-routes.ts
235979
- var import_fastify_plugin15 = __toESM(require_plugin2(), 1);
235980
- var routes14 = async (fastify2) => {
237152
+ var import_fastify_plugin16 = __toESM(require_plugin2(), 1);
237153
+ var routes15 = async (fastify2) => {
235981
237154
  const getAuthorizedSession = (req, reply, sessionId) => {
235982
237155
  const authResult = requireAuth(req, reply);
235983
237156
  if (authResult === null) return null;
@@ -236082,12 +237255,12 @@ var routes14 = async (fastify2) => {
236082
237255
  return reply.send({ ok: true });
236083
237256
  });
236084
237257
  };
236085
- var chat_session_routes_default = (0, import_fastify_plugin15.default)(routes14, { name: "chat-session-routes" });
237258
+ var chat_session_routes_default = (0, import_fastify_plugin16.default)(routes15, { name: "chat-session-routes" });
236086
237259
 
236087
237260
  // src/routes/task-routes.ts
236088
- var import_fastify_plugin16 = __toESM(require_plugin2(), 1);
237261
+ var import_fastify_plugin17 = __toESM(require_plugin2(), 1);
236089
237262
  import { randomUUID as randomUUID16 } from "crypto";
236090
- var routes15 = async (fastify2) => {
237263
+ var routes16 = async (fastify2) => {
236091
237264
  fastify2.get(
236092
237265
  "/api/projects/:projectId/tasks",
236093
237266
  async (req, reply) => {
@@ -236233,12 +237406,12 @@ Description: ${description}`,
236233
237406
  return reply.code(200).send({ success: true });
236234
237407
  });
236235
237408
  };
236236
- var task_routes_default = (0, import_fastify_plugin16.default)(routes15, { name: "task-routes" });
237409
+ var task_routes_default = (0, import_fastify_plugin17.default)(routes16, { name: "task-routes" });
236237
237410
 
236238
237411
  // src/routes/rule-routes.ts
236239
- var import_fastify_plugin17 = __toESM(require_plugin2(), 1);
237412
+ var import_fastify_plugin18 = __toESM(require_plugin2(), 1);
236240
237413
  import { randomUUID as randomUUID17 } from "crypto";
236241
- var routes16 = async (fastify2) => {
237414
+ var routes17 = async (fastify2) => {
236242
237415
  fastify2.get(
236243
237416
  "/api/projects/:projectId/rules",
236244
237417
  async (req, reply) => {
@@ -236324,12 +237497,12 @@ var routes16 = async (fastify2) => {
236324
237497
  return reply.code(200).send({ success: true });
236325
237498
  });
236326
237499
  };
236327
- var rule_routes_default = (0, import_fastify_plugin17.default)(routes16, { name: "rule-routes" });
237500
+ var rule_routes_default = (0, import_fastify_plugin18.default)(routes17, { name: "rule-routes" });
236328
237501
 
236329
237502
  // src/routes/command-routes.ts
236330
- var import_fastify_plugin18 = __toESM(require_plugin2(), 1);
237503
+ var import_fastify_plugin19 = __toESM(require_plugin2(), 1);
236331
237504
  import { randomUUID as randomUUID18 } from "crypto";
236332
- var routes17 = async (fastify2) => {
237505
+ var routes18 = async (fastify2) => {
236333
237506
  fastify2.get(
236334
237507
  "/api/projects/:projectId/commands",
236335
237508
  async (req, reply) => {
@@ -236398,83 +237571,274 @@ var routes17 = async (fastify2) => {
236398
237571
  return reply.code(200).send({ success: true });
236399
237572
  });
236400
237573
  };
236401
- var command_routes_default = (0, import_fastify_plugin18.default)(routes17, { name: "command-routes" });
237574
+ var command_routes_default = (0, import_fastify_plugin19.default)(routes18, { name: "command-routes" });
236402
237575
 
236403
237576
  // src/routes/workflow-run-routes.ts
236404
- var import_fastify_plugin19 = __toESM(require_plugin2(), 1);
237577
+ var import_fastify_plugin20 = __toESM(require_plugin2(), 1);
236405
237578
 
236406
237579
  // src/utils/review-brief.ts
236407
- var AI_TIMEOUT_MS2 = 15e3;
236408
- var PER_MESSAGE_MAX_CHARS = 1500;
236409
- var TOTAL_INPUT_MAX_CHARS = 24e3;
236410
- var HEAD_CHARS = 8e3;
236411
- var TAIL_CHARS = 15e3;
237580
+ var BASE_TIMEOUT_MS = 15e3;
237581
+ var TIMEOUT_MS_PER_1K_CHARS = 120;
237582
+ var MAX_TIMEOUT_MS = 6e4;
237583
+ var PER_MESSAGE_MAX_CHARS = 6e3;
237584
+ var COMPACT_TRIGGER_CHARS = 12e4;
237585
+ var COMPACT_SLICE_BUDGETS = [96e3, 24e3, 6e3];
237586
+ var RECENT_VERBATIM_RATIO = 0.3;
237587
+ var MIN_RECENT_VERBATIM_CHARS = 2e3;
237588
+ var MAX_FOLD_CALLS = 8;
237589
+ var COMPACT_SUMMARY_MAX_CHARS = 6e3;
237590
+ var COMPACT_SUMMARY_MAX_TOKENS = 1500;
237591
+ var REVERSALS_MAX_CHARS = 2e3;
237592
+ var REVERSALS_MAX_TOKENS = 500;
236412
237593
  var BRIEF_MAX_CHARS = 4e3;
237594
+ var BRIEF_MAX_TOKENS = 1e3;
237595
+ var REVERSAL_MIN_CHARS = 6e3;
237596
+ var SEP = "\n\n";
237597
+ var COMPACTION_NOTE = "_Note: the source conversation was compressed before distillation \u2014 older turns were summarized rather than read verbatim._";
237598
+ var BRIEF_TRUNCATION_NOTE = "\n\n_[brief truncated at the length limit \u2014 treat it as incomplete]_";
236413
237599
  var SYSTEM_PROMPT2 = [
236414
237600
  "You distill a coding-agent conversation into an intent brief for an independent code reviewer.",
236415
237601
  "The reviewer will NOT see the conversation \u2014 only your brief plus the actual code, so capture what the code alone cannot show:",
236416
237602
  "1. The original request and its goal.",
236417
- "2. Constraints and explicit user decisions, including approaches the user rejected.",
237603
+ "2. Constraints and decisions, including approaches that were rejected and why. A rejection counts whether the user made it or the agent made it under a principle the user set.",
236418
237604
  "3. Trade-offs or limitations that were acknowledged and accepted.",
236419
- "Do NOT include the agent's reasoning, self-assessment, or claims that the work is correct or complete \u2014 the reviewer must judge that independently.",
236420
- "Write concise markdown bullets under those numbered headings, under 400 words total, in the same language as the conversation. Reply with the brief only."
237605
+ "4. Behaviour established by actually running something \u2014 observed output, exit codes, error text, which stream an error arrived on. Report what was observed; that is evidence the reviewer cannot recover by reading code.",
237606
+ "Later statements supersede earlier ones on the same topic. An option explored at length early is often rejected later in a single sentence, and the rejection wins. Where the conversation ends in a decision table or summary, that is authoritative wherever it conflicts with earlier prose.",
237607
+ "The input may arrive as numbered compressed slices followed by verbatim recent turns. Slice numbers are chronological: a later slice overrules an earlier one.",
237608
+ "Decisions the agent made that the user did not contradict are binding \u2014 silence is agreement.",
237609
+ "Do NOT include the agent's self-assessment or any claim that the work is correct or complete \u2014 the reviewer must judge that independently.",
237610
+ "Every statement must trace to something in the conversation. If a heading has nothing real to report, say so rather than inventing plausible-sounding content.",
237611
+ "Write concise markdown bullets under those numbered headings, under 500 words total, in the same language as the conversation. Reply with the brief only."
236421
237612
  ].join("\n");
236422
- function capText(text2, max) {
236423
- return text2.length > max ? text2.slice(0, max) + "\u2026" : text2;
237613
+ var REVERSAL_SYSTEM_PROMPT = [
237614
+ "You read a coding-agent conversation and report ONLY the conclusions that were later reversed, dropped, or replaced.",
237615
+ "A design conversation converges by killing its own hypotheses: an option explored in depth early is often rejected later in one sentence, so weigh finality over how much was written.",
237616
+ "A reversal counts whether the user or the agent made it. Ignore rewording that leaves the decision intact.",
237617
+ "Output one line per reversal: SUPERSEDED: <what was concluded earlier> -> <what replaced it> (<why>)",
237618
+ "Report the most decisive reversals first and stay under 200 words \u2014 a list that runs long will be discarded whole, so prioritise rather than pad.",
237619
+ "If nothing was reversed, reply with exactly: NONE"
237620
+ ].join("\n");
237621
+ var COMPACT_SYSTEM_PROMPT = [
237622
+ "You compress one slice of a coding-agent conversation. A later pass will distill the whole thing into an intent brief for a code reviewer.",
237623
+ "You are told which slice of how many this is. Other slices are compressed separately and you cannot see them, so never present a conclusion here as final \u2014 a later slice may overturn it.",
237624
+ "Preserve, in this order of priority:",
237625
+ "1. Reversals and corrections \u2014 any conclusion that was dropped, replaced, or corrected. Quote the deciding sentence verbatim.",
237626
+ "2. Constraints and decisions the user stated, and approaches that were rejected, with the reason.",
237627
+ "3. Behaviour established by actually running something: observed output, exit codes, error text, which stream an error arrived on.",
237628
+ "4. Trade-offs that were acknowledged and accepted.",
237629
+ "Drop: exploration narrative, restated background, tool mechanics, and anything a reviewer could recover by reading the code.",
237630
+ "Stay under 800 words. An over-long slice is discarded whole rather than cut short, so drop lower-priority material instead of running over.",
237631
+ "Keep the conversation's own language. Reply with the compressed slice only, no preamble."
237632
+ ].join("\n");
237633
+ function timeoutForInput(chars) {
237634
+ return Math.min(MAX_TIMEOUT_MS, BASE_TIMEOUT_MS + Math.floor(chars / 1e3) * TIMEOUT_MS_PER_1K_CHARS);
237635
+ }
237636
+ function isSizeRelatedFailure(error48) {
237637
+ const err = error48;
237638
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") return true;
237639
+ const message = err?.message ?? "";
237640
+ return /context[ _-]?length|maximum context|too many tokens|token limit|prompt is too long|request too large|timed? ?out/i.test(
237641
+ message
237642
+ );
236424
237643
  }
236425
- function serializeConversationForBrief(messages) {
236426
- const lines = [];
237644
+ function withinLimit(text2, max, label) {
237645
+ if (text2.length <= max) return true;
237646
+ console.warn(`[ReviewBrief] ${label} ran to ${text2.length} chars (limit ${max}) \u2014 discarding rather than truncating`);
237647
+ return false;
237648
+ }
237649
+ function capMessage(text2) {
237650
+ return text2.length > PER_MESSAGE_MAX_CHARS ? text2.slice(0, PER_MESSAGE_MAX_CHARS) + "\u2026" : text2;
237651
+ }
237652
+ function toEntries(messages) {
237653
+ const entries = [];
236427
237654
  for (const msg of messages) {
236428
237655
  if (!msg) continue;
236429
237656
  if (msg.type === "user" && !msg.event) {
236430
237657
  const text2 = extractUserText(msg.content).trim();
236431
- if (text2) lines.push(`User: ${capText(text2, PER_MESSAGE_MAX_CHARS)}`);
237658
+ if (text2) entries.push({ role: "User", text: capMessage(text2) });
236432
237659
  } else if (msg.type === "assistant" && typeof msg.content === "string") {
236433
237660
  const text2 = msg.content.trim();
236434
- if (text2) lines.push(`Agent: ${capText(text2, PER_MESSAGE_MAX_CHARS)}`);
237661
+ if (text2) entries.push({ role: "Agent", text: capMessage(text2) });
236435
237662
  }
236436
237663
  }
236437
- const full = lines.join("\n\n");
236438
- if (full.length <= TOTAL_INPUT_MAX_CHARS) return full;
236439
- return `${full.slice(0, HEAD_CHARS)}
237664
+ return entries;
237665
+ }
237666
+ function render(entry) {
237667
+ return `${entry.role}: ${entry.text}`;
237668
+ }
237669
+ function renderAll(entries) {
237670
+ return entries.map(render).join(SEP);
237671
+ }
237672
+ function serializeConversationForBrief(messages) {
237673
+ return renderAll(toEntries(messages));
237674
+ }
237675
+ function splitRecent(entries, maxChars) {
237676
+ let size = 0;
237677
+ let i = entries.length;
237678
+ while (i > 0) {
237679
+ const len = render(entries[i - 1]).length + SEP.length;
237680
+ if (size + len > maxChars) break;
237681
+ size += len;
237682
+ i--;
237683
+ }
237684
+ return { older: entries.slice(0, i), recent: entries.slice(i) };
237685
+ }
237686
+ function chunkEntries(entries, maxChars) {
237687
+ const chunks = [];
237688
+ let current = [];
237689
+ let size = 0;
237690
+ for (const entry of entries) {
237691
+ const len = render(entry).length + SEP.length;
237692
+ if (current.length > 0 && size + len > maxChars) {
237693
+ chunks.push(current);
237694
+ current = [];
237695
+ size = 0;
237696
+ }
237697
+ current.push(entry);
237698
+ size += len;
237699
+ }
237700
+ if (current.length > 0) chunks.push(current);
237701
+ return chunks;
237702
+ }
237703
+ function telemetryFor(functionId, userId) {
237704
+ return userId ? {
237705
+ isEnabled: true,
237706
+ functionId,
237707
+ metadata: { userId, tags: ["vibedeckx", functionId] }
237708
+ } : void 0;
237709
+ }
237710
+ async function compactChunk(model, chunk, part, total, userId) {
237711
+ try {
237712
+ const result = await generateText({
237713
+ model,
237714
+ system: COMPACT_SYSTEM_PROMPT,
237715
+ prompt: `Slice ${part} of ${total}.
237716
+
237717
+ ${chunk}`,
237718
+ timeout: timeoutForInput(chunk.length),
237719
+ maxOutputTokens: COMPACT_SUMMARY_MAX_TOKENS,
237720
+ experimental_telemetry: telemetryFor("review-intent-brief-compact", userId)
237721
+ });
237722
+ const text2 = (result.text ?? "").trim();
237723
+ if (text2.length === 0) return null;
237724
+ return withinLimit(text2, COMPACT_SUMMARY_MAX_CHARS, `slice ${part}/${total} summary`) ? text2 : null;
237725
+ } catch (error48) {
237726
+ if (isSizeRelatedFailure(error48)) throw error48;
237727
+ console.warn(`[ReviewBrief] compaction of slice ${part}/${total} failed:`, error48.message);
237728
+ return null;
237729
+ }
237730
+ }
237731
+ async function compactConversation(model, messages, options = {}) {
237732
+ const sliceChars = options.sliceChars ?? COMPACT_SLICE_BUDGETS[0];
237733
+ const recentChars = Math.max(MIN_RECENT_VERBATIM_CHARS, Math.floor(sliceChars * RECENT_VERBATIM_RATIO));
237734
+ const entries = toEntries(messages);
237735
+ if (entries.length === 0) return null;
237736
+ const { older, recent } = splitRecent(entries, recentChars);
237737
+ if (older.length === 0) return null;
237738
+ const chunks = chunkEntries(older, sliceChars);
237739
+ if (chunks.length > MAX_FOLD_CALLS) {
237740
+ console.warn(
237741
+ `[ReviewBrief] conversation needs ${chunks.length} slices of ${sliceChars} chars (max ${MAX_FOLD_CALLS}) \u2014 falling back to tier 2`
237742
+ );
237743
+ return null;
237744
+ }
237745
+ const settled = await Promise.allSettled(
237746
+ chunks.map((chunk, i) => compactChunk(model, renderAll(chunk), i + 1, chunks.length, options.userId))
237747
+ );
237748
+ const sizeRejection = settled.find((r) => r.status === "rejected" && isSizeRelatedFailure(r.reason));
237749
+ if (sizeRejection) throw sizeRejection.reason;
237750
+ if (settled.some((r) => r.status === "rejected" || r.value === null)) return null;
237751
+ const parts = settled.map(
237752
+ (r, i) => `[Compressed slice ${i + 1} of ${chunks.length}]
237753
+ ${r.value}`
237754
+ );
237755
+ if (recent.length > 0) parts.push(`[Recent turns, verbatim]
237756
+ ${renderAll(recent)}`);
237757
+ return parts.join(SEP);
237758
+ }
237759
+ function buildBriefPrompt(conversation, reversals) {
237760
+ const preamble = reversals ? "Conclusions this conversation reversed. The right-hand side is the current state \u2014 never report the left-hand side as what was decided:\n" + reversals + SEP : "";
237761
+ return `${preamble}Distill this conversation into an intent brief:
236440
237762
 
236441
- [\u2026 middle of the conversation omitted \u2026]
237763
+ ${conversation}`;
237764
+ }
237765
+ function appendCompactionNote(brief, compacted) {
237766
+ return compacted ? `${brief}${SEP}${COMPACTION_NOTE}` : brief;
237767
+ }
237768
+ async function extractReversalsWithModel(model, conversation, options = {}) {
237769
+ if (conversation.trim().length < REVERSAL_MIN_CHARS) return null;
237770
+ try {
237771
+ const result = await generateText({
237772
+ model,
237773
+ system: REVERSAL_SYSTEM_PROMPT,
237774
+ prompt: `Report the reversals in this conversation:
236442
237775
 
236443
- ${full.slice(-TAIL_CHARS)}`;
237776
+ ${conversation}`,
237777
+ timeout: timeoutForInput(conversation.length),
237778
+ maxOutputTokens: REVERSALS_MAX_TOKENS,
237779
+ experimental_telemetry: telemetryFor("review-intent-brief-reversals", options.userId)
237780
+ });
237781
+ const text2 = (result.text ?? "").trim();
237782
+ if (text2.length === 0 || /^none\b/i.test(text2)) return null;
237783
+ return withinLimit(text2, REVERSALS_MAX_CHARS, "reversal list") ? text2 : null;
237784
+ } catch (error48) {
237785
+ if (!isSizeRelatedFailure(error48)) throw error48;
237786
+ console.warn("[ReviewBrief] reversal pass hit a size limit \u2014 skipping it:", error48.message);
237787
+ return null;
237788
+ }
236444
237789
  }
236445
237790
  async function generateIntentBriefWithModel(model, conversation, options = {}) {
236446
237791
  if (conversation.trim().length === 0) return null;
236447
- const telemetry = options.userId ? {
236448
- isEnabled: true,
236449
- functionId: "review-intent-brief",
236450
- metadata: {
236451
- userId: options.userId,
236452
- tags: ["vibedeckx", "review-intent-brief"]
236453
- }
236454
- } : void 0;
236455
237792
  try {
236456
237793
  const result = await generateText({
236457
237794
  model,
236458
237795
  system: SYSTEM_PROMPT2,
236459
- prompt: `Distill this conversation into an intent brief:
236460
-
236461
- ${conversation}`,
236462
- timeout: AI_TIMEOUT_MS2,
236463
- experimental_telemetry: telemetry
237796
+ prompt: buildBriefPrompt(conversation, options.reversals ?? null),
237797
+ timeout: timeoutForInput(conversation.length),
237798
+ maxOutputTokens: BRIEF_MAX_TOKENS,
237799
+ experimental_telemetry: telemetryFor("review-intent-brief", options.userId)
236464
237800
  });
236465
237801
  const text2 = (result.text ?? "").trim();
236466
- return text2.length > 0 ? capText(text2, BRIEF_MAX_CHARS) : null;
237802
+ if (text2.length === 0) return null;
237803
+ return text2.length <= BRIEF_MAX_CHARS ? text2 : text2.slice(0, BRIEF_MAX_CHARS) + BRIEF_TRUNCATION_NOTE;
236467
237804
  } catch (error48) {
237805
+ if (options.rethrowSizeFailures && isSizeRelatedFailure(error48)) throw error48;
236468
237806
  console.warn("[ReviewBrief] AI generation failed:", error48.message);
236469
237807
  return null;
236470
237808
  }
236471
237809
  }
237810
+ async function distill(model, conversation, userId, rethrowSizeFailures) {
237811
+ const reversals = await extractReversalsWithModel(model, conversation, { userId });
237812
+ return generateIntentBriefWithModel(model, conversation, { userId, reversals, rethrowSizeFailures });
237813
+ }
236472
237814
  async function generateIntentBrief(storage, userId, messages) {
236473
237815
  try {
236474
237816
  if (!await isChatModelConfigured(storage, userId)) return null;
236475
237817
  const conversation = serializeConversationForBrief(messages);
236476
237818
  if (!conversation) return null;
236477
- return await generateIntentBriefWithModel(await resolveFastChatModel(storage, userId), conversation, { userId });
237819
+ const model = await resolveFastChatModel(storage, userId);
237820
+ if (conversation.length <= COMPACT_TRIGGER_CHARS) {
237821
+ try {
237822
+ const brief = await distill(model, conversation, userId, true);
237823
+ return brief === null ? null : appendCompactionNote(brief, false);
237824
+ } catch (error48) {
237825
+ if (!isSizeRelatedFailure(error48)) throw error48;
237826
+ console.warn("[ReviewBrief] full conversation rejected as too large \u2014 compacting and retrying");
237827
+ }
237828
+ }
237829
+ for (const sliceChars of COMPACT_SLICE_BUDGETS) {
237830
+ try {
237831
+ const compacted = await compactConversation(model, messages, { userId, sliceChars });
237832
+ if (compacted === null) return null;
237833
+ const brief = await distill(model, compacted, userId, true);
237834
+ return brief === null ? null : appendCompactionNote(brief, true);
237835
+ } catch (error48) {
237836
+ if (!isSizeRelatedFailure(error48)) throw error48;
237837
+ console.warn(`[ReviewBrief] ${sliceChars}-char slices still too large \u2014 retrying smaller`);
237838
+ }
237839
+ }
237840
+ console.warn("[ReviewBrief] could not compact the conversation small enough \u2014 falling back to tier 2");
237841
+ return null;
236478
237842
  } catch (error48) {
236479
237843
  console.warn("[ReviewBrief] generation failed:", error48.message);
236480
237844
  return null;
@@ -236515,7 +237879,7 @@ function errStatus(err) {
236515
237879
  return 500;
236516
237880
  }
236517
237881
  }
236518
- async function routes18(fastify2) {
237882
+ async function routes19(fastify2) {
236519
237883
  const remoteRunMap = /* @__PURE__ */ new Map();
236520
237884
  const TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "cancelled", "failed"]);
236521
237885
  const trackRemoteRun = (localRun, info) => {
@@ -236599,6 +237963,12 @@ async function routes18(fastify2) {
236599
237963
  if (!clientProvidedBrief && !bareReviewerSessionId) {
236600
237964
  intentBrief2 = await distillIntentBrief(userId, sourceSessionId);
236601
237965
  }
237966
+ if (reviewerSessionId && !await fastify2.remoteNotificationSync.prepareForNewTurn(reviewerSessionId)) {
237967
+ return reply.code(502).send({
237968
+ error: "Could not reach the remote server to prepare notification delivery",
237969
+ errorCode: "notification_baseline_failed"
237970
+ });
237971
+ }
236602
237972
  const result = await proxyAuto(remoteInfo, "POST", "/api/path/workflow-runs", {
236603
237973
  sourceSessionId: remoteInfo.remoteSessionId,
236604
237974
  reviewFocus,
@@ -236632,7 +238002,12 @@ async function routes18(fastify2) {
236632
238002
  projectId,
236633
238003
  remoteInfo.remoteServerId,
236634
238004
  bareRun.reviewer_session_id,
236635
- bareRun.branch
238005
+ bareRun.branch,
238006
+ "from_start"
238007
+ );
238008
+ await fastify2.remoteNotificationSync.extendWatch(localRun.reviewer_session_id);
238009
+ fastify2.remoteNotificationSync.enqueue(
238010
+ () => fastify2.remoteNotificationSync.syncServer(remoteInfo.remoteServerId, { includeExpired: true })
236636
238011
  );
236637
238012
  ensureRemoteAgentStream(localRun.reviewer_session_id, {
236638
238013
  remoteSessionMap: fastify2.remoteSessionMap,
@@ -236641,6 +238016,11 @@ async function routes18(fastify2) {
236641
238016
  eventBus: fastify2.eventBus,
236642
238017
  agentSessionManager: fastify2.agentSessionManager
236643
238018
  });
238019
+ fastify2.agentSessionManager.emitBranchActivityIfChanged(projectId, bareRun.branch, {
238020
+ activity: "working",
238021
+ since: Date.now(),
238022
+ sessionId: localRun.reviewer_session_id
238023
+ });
236644
238024
  fastify2.eventBus.emit({
236645
238025
  type: "session:process",
236646
238026
  projectId,
@@ -236755,7 +238135,8 @@ async function routes18(fastify2) {
236755
238135
  projectId,
236756
238136
  remoteInfo.remoteServerId,
236757
238137
  bareCandidate.sessionId,
236758
- remoteInfo.branch ?? null
238138
+ remoteInfo.branch ?? null,
238139
+ "from_now"
236759
238140
  );
236760
238141
  }
236761
238142
  return reply.send({ candidate: candidate2 });
@@ -236949,10 +238330,10 @@ async function routes18(fastify2) {
236949
238330
  return reply.send({ runs });
236950
238331
  });
236951
238332
  }
236952
- var workflow_run_routes_default = (0, import_fastify_plugin19.default)(routes18, { name: "workflow-run-routes" });
238333
+ var workflow_run_routes_default = (0, import_fastify_plugin20.default)(routes19, { name: "workflow-run-routes" });
236953
238334
 
236954
238335
  // src/routes/settings-routes.ts
236955
- var import_fastify_plugin20 = __toESM(require_plugin2(), 1);
238336
+ var import_fastify_plugin21 = __toESM(require_plugin2(), 1);
236956
238337
  var SettingsValidationError = class extends Error {
236957
238338
  };
236958
238339
  var DEFAULT_PROXY_CONFIG = { type: "none", host: "", port: 0 };
@@ -237016,7 +238397,7 @@ function requireSettingsUser(req, reply) {
237016
238397
  if (auth === null) return null;
237017
238398
  return resolveUserId(auth);
237018
238399
  }
237019
- var routes19 = async (fastify2) => {
238400
+ var routes20 = async (fastify2) => {
237020
238401
  fastify2.get("/api/settings/agent-processes", async (req, reply) => {
237021
238402
  if (requireAuth(req, reply) === null) return;
237022
238403
  const saved = await fastify2.storage.settings.get("agentProcesses");
@@ -237290,11 +238671,11 @@ var routes19 = async (fastify2) => {
237290
238671
  return reply.code(200).send(updated);
237291
238672
  });
237292
238673
  };
237293
- var settings_routes_default = (0, import_fastify_plugin20.default)(routes19, { name: "settings-routes" });
238674
+ var settings_routes_default = (0, import_fastify_plugin21.default)(routes20, { name: "settings-routes" });
237294
238675
 
237295
238676
  // src/routes/translate-routes.ts
237296
- var import_fastify_plugin21 = __toESM(require_plugin2(), 1);
237297
- var routes20 = async (fastify2) => {
238677
+ var import_fastify_plugin22 = __toESM(require_plugin2(), 1);
238678
+ var routes21 = async (fastify2) => {
237298
238679
  fastify2.post(
237299
238680
  "/api/translate",
237300
238681
  async (req, reply) => {
@@ -237335,10 +238716,10 @@ ${text2}`,
237335
238716
  }
237336
238717
  );
237337
238718
  };
237338
- var translate_routes_default = (0, import_fastify_plugin21.default)(routes20, { name: "translate-routes" });
238719
+ var translate_routes_default = (0, import_fastify_plugin22.default)(routes21, { name: "translate-routes" });
237339
238720
 
237340
238721
  // src/routes/websocket-routes.ts
237341
- var import_fastify_plugin22 = __toESM(require_plugin2(), 1);
238722
+ var import_fastify_plugin23 = __toESM(require_plugin2(), 1);
237342
238723
 
237343
238724
  // src/routes/executor-stream-handlers.ts
237344
238725
  import { randomUUID as randomUUID19 } from "crypto";
@@ -237616,7 +238997,7 @@ async function userOwnsSession(fastify2, sessionId, userId) {
237616
238997
  }
237617
238998
 
237618
238999
  // src/routes/websocket-routes.ts
237619
- var routes21 = async (fastify2) => {
239000
+ var routes22 = async (fastify2) => {
237620
239001
  fastify2.reverseConnectManager.setStatusChangeHandler((remoteServerId, status) => {
237621
239002
  if (status !== "online") return;
237622
239003
  const cache2 = fastify2.remotePatchCache;
@@ -238003,13 +239384,13 @@ var routes21 = async (fastify2) => {
238003
239384
  );
238004
239385
  });
238005
239386
  };
238006
- var websocket_routes_default = (0, import_fastify_plugin22.default)(routes21, { name: "websocket-routes" });
239387
+ var websocket_routes_default = (0, import_fastify_plugin23.default)(routes22, { name: "websocket-routes" });
238007
239388
 
238008
239389
  // src/routes/reverse-connect-routes.ts
238009
- var import_fastify_plugin23 = __toESM(require_plugin2(), 1);
239390
+ var import_fastify_plugin24 = __toESM(require_plugin2(), 1);
238010
239391
  import { randomBytes as randomBytes2, createHash as createHash3, verify as cryptoVerify } from "crypto";
238011
239392
  var MACHINE_HANDSHAKE_TIMEOUT_MS = 5e3;
238012
- var routes22 = async (fastify2) => {
239393
+ var routes23 = async (fastify2) => {
238013
239394
  fastify2.get("/api/reverse-connect/identity", async (req, reply) => {
238014
239395
  const token = req.headers["x-vibedeckx-connect-token"];
238015
239396
  if (typeof token !== "string" || token.length === 0) {
@@ -238119,10 +239500,10 @@ var routes22 = async (fastify2) => {
238119
239500
  );
238120
239501
  });
238121
239502
  };
238122
- var reverse_connect_routes_default = (0, import_fastify_plugin23.default)(routes22, { name: "reverse-connect-routes" });
239503
+ var reverse_connect_routes_default = (0, import_fastify_plugin24.default)(routes23, { name: "reverse-connect-routes" });
238123
239504
 
238124
239505
  // src/routes/event-routes.ts
238125
- var import_fastify_plugin24 = __toESM(require_plugin2(), 1);
239506
+ var import_fastify_plugin25 = __toESM(require_plugin2(), 1);
238126
239507
  function toWireEvent(event) {
238127
239508
  if (event.type === "executor:stopped" && (event.tailOutput !== void 0 || event.finalResult !== void 0)) {
238128
239509
  const { tailOutput: _tailOutput, finalResult: _finalResult, ...rest } = event;
@@ -238130,7 +239511,7 @@ function toWireEvent(event) {
238130
239511
  }
238131
239512
  return event;
238132
239513
  }
238133
- var routes23 = async (fastify2) => {
239514
+ var routes24 = async (fastify2) => {
238134
239515
  fastify2.get("/api/events", async (req, reply) => {
238135
239516
  let userId = null;
238136
239517
  if (fastify2.authEnabled) {
@@ -238188,10 +239569,10 @@ var routes23 = async (fastify2) => {
238188
239569
  await reply;
238189
239570
  });
238190
239571
  };
238191
- var event_routes_default = (0, import_fastify_plugin24.default)(routes23, { name: "event-routes" });
239572
+ var event_routes_default = (0, import_fastify_plugin25.default)(routes24, { name: "event-routes" });
238192
239573
 
238193
239574
  // src/routes/terminal-routes.ts
238194
- var import_fastify_plugin25 = __toESM(require_plugin2(), 1);
239575
+ var import_fastify_plugin26 = __toESM(require_plugin2(), 1);
238195
239576
  import path13 from "path";
238196
239577
  async function getRemoteConfig5(fastify2, project, remoteServerId) {
238197
239578
  const remotes = await fastify2.storage.projectRemotes.getByProject(project.id);
@@ -238216,7 +239597,7 @@ async function getRemoteConfig5(fastify2, project, remoteServerId) {
238216
239597
  }
238217
239598
  return null;
238218
239599
  }
238219
- var routes24 = async (fastify2) => {
239600
+ var routes25 = async (fastify2) => {
238220
239601
  fastify2.post("/api/path/terminals", async (req, reply) => {
238221
239602
  const { path: projectPath, branch } = req.body;
238222
239603
  if (!projectPath) {
@@ -238399,11 +239780,11 @@ var routes24 = async (fastify2) => {
238399
239780
  }
238400
239781
  );
238401
239782
  };
238402
- var terminal_routes_default = (0, import_fastify_plugin25.default)(routes24, { name: "terminal-routes" });
239783
+ var terminal_routes_default = (0, import_fastify_plugin26.default)(routes25, { name: "terminal-routes" });
238403
239784
 
238404
239785
  // src/routes/browser-routes.ts
238405
- var import_fastify_plugin26 = __toESM(require_plugin2(), 1);
238406
- var routes25 = async (fastify2) => {
239786
+ var import_fastify_plugin27 = __toESM(require_plugin2(), 1);
239787
+ var routes26 = async (fastify2) => {
238407
239788
  const ensureProjectAccess = async (req, reply) => {
238408
239789
  const userId = requireAuth(req, reply);
238409
239790
  if (userId === null) return null;
@@ -238474,10 +239855,10 @@ var routes25 = async (fastify2) => {
238474
239855
  return reply.code(200).send({ ok: true });
238475
239856
  });
238476
239857
  };
238477
- var browser_routes_default = (0, import_fastify_plugin26.default)(routes25, { name: "browser-routes" });
239858
+ var browser_routes_default = (0, import_fastify_plugin27.default)(routes26, { name: "browser-routes" });
238478
239859
 
238479
239860
  // src/routes/browser-proxy-routes.ts
238480
- var import_fastify_plugin27 = __toESM(require_plugin2(), 1);
239861
+ var import_fastify_plugin28 = __toESM(require_plugin2(), 1);
238481
239862
  import { randomUUID as randomUUID20 } from "crypto";
238482
239863
 
238483
239864
  // src/utils/ssrf-guard.ts
@@ -238973,7 +240354,7 @@ function generateInjectedScript(projectId, targetOrigin, proxyWsPrefix) {
238973
240354
  })();
238974
240355
  </script>`;
238975
240356
  }
238976
- var routes26 = async (fastify2) => {
240357
+ var routes27 = async (fastify2) => {
238977
240358
  fastify2.get("/api/projects/:id/browser/proxy/*", async (req, reply) => {
238978
240359
  const userId = requireAuth(req, reply);
238979
240360
  if (userId === null) return;
@@ -239144,10 +240525,10 @@ var routes26 = async (fastify2) => {
239144
240525
  });
239145
240526
  });
239146
240527
  };
239147
- var browser_proxy_routes_default = (0, import_fastify_plugin27.default)(routes26, { name: "browser-proxy-routes" });
240528
+ var browser_proxy_routes_default = (0, import_fastify_plugin28.default)(routes27, { name: "browser-proxy-routes" });
239148
240529
 
239149
240530
  // src/routes/cross-remote-target-routes.ts
239150
- var import_fastify_plugin28 = __toESM(require_plugin2(), 1);
240531
+ var import_fastify_plugin29 = __toESM(require_plugin2(), 1);
239151
240532
  import path14 from "path";
239152
240533
  import { promises as fs3 } from "fs";
239153
240534
 
@@ -239227,7 +240608,7 @@ var clampTimeoutMs = (timeoutSec) => {
239227
240608
  const requested = typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : DEFAULT_TIMEOUT_SEC;
239228
240609
  return Math.min(requested, MAX_TIMEOUT_SEC) * 1e3;
239229
240610
  };
239230
- var routes27 = async (fastify2) => {
240611
+ var routes28 = async (fastify2) => {
239231
240612
  fastify2.post(
239232
240613
  "/api/path/cross-remote/exec",
239233
240614
  async (request, reply) => {
@@ -239335,10 +240716,10 @@ var routes27 = async (fastify2) => {
239335
240716
  return reply.send(result);
239336
240717
  });
239337
240718
  };
239338
- var cross_remote_target_routes_default = (0, import_fastify_plugin28.default)(routes27, { name: "cross-remote-target-routes" });
240719
+ var cross_remote_target_routes_default = (0, import_fastify_plugin29.default)(routes28, { name: "cross-remote-target-routes" });
239339
240720
 
239340
240721
  // src/routes/cross-remote-mcp-routes.ts
239341
- var import_fastify_plugin29 = __toESM(require_plugin2(), 1);
240722
+ var import_fastify_plugin30 = __toESM(require_plugin2(), 1);
239342
240723
  var PROTOCOL_VERSION = "2024-11-05";
239343
240724
  var AUDIT_ARGS_MAX = 1024;
239344
240725
  var NOT_ACCESSIBLE = "remote not found or not accessible";
@@ -239441,7 +240822,7 @@ var textResult = (text2, isError = false) => ({
239441
240822
  content: [{ type: "text", text: text2 }],
239442
240823
  ...isError ? { isError: true } : {}
239443
240824
  });
239444
- var routes28 = async (fastify2) => {
240825
+ var routes29 = async (fastify2) => {
239445
240826
  const guard = new SessionConcurrencyGuard();
239446
240827
  let cachedSecret;
239447
240828
  const getSecret = () => {
@@ -239564,10 +240945,10 @@ var routes28 = async (fastify2) => {
239564
240945
  }
239565
240946
  });
239566
240947
  };
239567
- var cross_remote_mcp_routes_default = (0, import_fastify_plugin29.default)(routes28, { name: "cross-remote-mcp-routes" });
240948
+ var cross_remote_mcp_routes_default = (0, import_fastify_plugin30.default)(routes29, { name: "cross-remote-mcp-routes" });
239568
240949
 
239569
240950
  // src/routes/schedule-routes.ts
239570
- var import_fastify_plugin30 = __toESM(require_plugin2(), 1);
240951
+ var import_fastify_plugin31 = __toESM(require_plugin2(), 1);
239571
240952
  import { randomUUID as randomUUID21 } from "crypto";
239572
240953
  import path15 from "path";
239573
240954
  var RUN_TYPES = ["command", "prompt"];
@@ -239583,7 +240964,7 @@ function validateResolved(b2) {
239583
240964
  if (cronError) return `Invalid cron expression: ${cronError}`;
239584
240965
  return null;
239585
240966
  }
239586
- var routes29 = async (fastify2) => {
240967
+ var routes30 = async (fastify2) => {
239587
240968
  const getAuthorizedSchedule = async (id, userId, reply) => {
239588
240969
  const schedule = await fastify2.storage.scheduledTasks.getById(id);
239589
240970
  if (!schedule) {
@@ -239755,7 +241136,7 @@ var routes29 = async (fastify2) => {
239755
241136
  }
239756
241137
  );
239757
241138
  };
239758
- var schedule_routes_default = (0, import_fastify_plugin30.default)(routes29, { name: "schedule-routes" });
241139
+ var schedule_routes_default = (0, import_fastify_plugin31.default)(routes30, { name: "schedule-routes" });
239759
241140
 
239760
241141
  // src/search/catalog.ts
239761
241142
  var parseDbTimestamp3 = (ts) => {
@@ -239959,7 +241340,8 @@ var searchRoutes = async (fastify2) => {
239959
241340
  target.projectId,
239960
241341
  target.targetId,
239961
241342
  s3.id,
239962
- mapBranch
241343
+ mapBranch,
241344
+ "from_now"
239963
241345
  );
239964
241346
  return { ...s3, id: localSessionId };
239965
241347
  }));
@@ -239996,6 +241378,44 @@ var searchRoutes = async (fastify2) => {
239996
241378
  };
239997
241379
  var search_routes_default = searchRoutes;
239998
241380
 
241381
+ // src/routes/notification-routes.ts
241382
+ var import_fastify_plugin32 = __toESM(require_plugin2(), 1);
241383
+ var MAX_LIMIT = 500;
241384
+ var DEFAULT_LIMIT = 100;
241385
+ var routes31 = async (fastify2) => {
241386
+ fastify2.get(
241387
+ "/api/notifications",
241388
+ async (req, reply) => {
241389
+ const authResult = requireAuth(req, reply);
241390
+ if (authResult === null) return;
241391
+ const userId = resolveUserId(authResult);
241392
+ const requested = Number(req.query.limit);
241393
+ const limit = Number.isFinite(requested) && requested > 0 ? Math.min(Math.floor(requested), MAX_LIMIT) : DEFAULT_LIMIT;
241394
+ const notifications = await fastify2.storage.notifications.listForUser(userId, {
241395
+ limit,
241396
+ unreadOnly: req.query.unread === "true"
241397
+ });
241398
+ return reply.code(200).send({ notifications });
241399
+ }
241400
+ );
241401
+ fastify2.patch("/api/notifications/:id/read", async (req, reply) => {
241402
+ const authResult = requireAuth(req, reply);
241403
+ if (authResult === null) return;
241404
+ const userId = resolveUserId(authResult);
241405
+ const ok = await fastify2.storage.notifications.markRead(req.params.id, userId);
241406
+ if (!ok) return reply.code(404).send({ error: "Not found" });
241407
+ return reply.code(200).send({ ok: true });
241408
+ });
241409
+ fastify2.post("/api/notifications/read-all", async (req, reply) => {
241410
+ const authResult = requireAuth(req, reply);
241411
+ if (authResult === null) return;
241412
+ const userId = resolveUserId(authResult);
241413
+ await fastify2.storage.notifications.markAllRead(userId);
241414
+ return reply.code(200).send({ ok: true });
241415
+ });
241416
+ };
241417
+ var notification_routes_default = (0, import_fastify_plugin32.default)(routes31, { name: "notification-routes" });
241418
+
239999
241419
  // ../../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
241420
  import { Readable as Readable3 } from "stream";
240001
241421
  var fastifyRequestToRequest = (req) => {
@@ -240046,7 +241466,7 @@ var requestToProxyRequest = (req) => {
240046
241466
 
240047
241467
  // ../../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
241468
  init_dist();
240049
- var import_fastify_plugin31 = __toESM(require_plugin2(), 1);
241469
+ var import_fastify_plugin33 = __toESM(require_plugin2(), 1);
240050
241470
 
240051
241471
  // ../../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
241472
  init_chunk_JW73PKED();
@@ -240334,7 +241754,7 @@ var plugin = (instance, opts, done) => {
240334
241754
  instance.addHook(hookName, withClerkMiddleware(opts));
240335
241755
  done();
240336
241756
  };
240337
- var clerkPlugin = (0, import_fastify_plugin31.default)(plugin, {
241757
+ var clerkPlugin = (0, import_fastify_plugin33.default)(plugin, {
240338
241758
  name: "@clerk/fastify",
240339
241759
  fastify: "5.x"
240340
241760
  });
@@ -241223,6 +242643,7 @@ var createServer = async (opts) => {
241223
242643
  authEnabled,
241224
242644
  clerkPublishableKey: authEnabled ? process.env.CLERK_PUBLISHABLE_KEY : void 0,
241225
242645
  localProjectsEnabled: !noLocalProjects,
242646
+ discordInviteUrl: process.env.VIBEDECKX_DISCORD_URL || void 0,
241226
242647
  // Capability flag for the reverse-connect identity preflight. Workers
241227
242648
  // check this before calling /api/reverse-connect/identity so an auth
241228
242649
  // middleware 401 from an older hub (which lacks the endpoint AND its
@@ -241282,6 +242703,8 @@ var createServer = async (opts) => {
241282
242703
  server.register(settings_routes_default);
241283
242704
  server.register(translate_routes_default);
241284
242705
  server.register(event_routes_default);
242706
+ server.register(notification_routes_default);
242707
+ server.register(notification_outbox_routes_default);
241285
242708
  server.register(terminal_routes_default);
241286
242709
  server.register(browser_routes_default);
241287
242710
  server.register(browser_proxy_routes_default);
@@ -243623,7 +245046,7 @@ var connectRoutes = buildRouteMap({
243623
245046
  defaultCommand: "run",
243624
245047
  docs: { brief: "Connect to a Vibedeckx server as an inbound node" }
243625
245048
  });
243626
- var routes30 = buildRouteMap({
245049
+ var routes32 = buildRouteMap({
243627
245050
  routes: {
243628
245051
  start: startCommand,
243629
245052
  connect: connectRoutes
@@ -243633,7 +245056,7 @@ var routes30 = buildRouteMap({
243633
245056
  brief: "Vibedeckx - AI-powered app generator"
243634
245057
  }
243635
245058
  });
243636
- var program = buildApplication(routes30, {
245059
+ var program = buildApplication(routes32, {
243637
245060
  name: "vibedeckx",
243638
245061
  versionInfo: {
243639
245062
  currentVersion: readPackageVersion()