@centralu/host-linux-x64 0.1.0-beta.21 → 0.1.0-beta.23

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.
package/host/main.mjs CHANGED
@@ -3235,7 +3235,7 @@ var require_stream = __commonJS({
3235
3235
  };
3236
3236
  duplex._final = function(callback) {
3237
3237
  if (ws2.readyState === ws2.CONNECTING) {
3238
- ws2.once("open", function open5() {
3238
+ ws2.once("open", function open6() {
3239
3239
  duplex._final(callback);
3240
3240
  });
3241
3241
  return;
@@ -3256,7 +3256,7 @@ var require_stream = __commonJS({
3256
3256
  };
3257
3257
  duplex._write = function(chunk, encoding, callback) {
3258
3258
  if (ws2.readyState === ws2.CONNECTING) {
3259
- ws2.once("open", function open5() {
3259
+ ws2.once("open", function open6() {
3260
3260
  duplex._write(chunk, encoding, callback);
3261
3261
  });
3262
3262
  return;
@@ -42960,12 +42960,12 @@ function refuse(message, io2 = realIo) {
42960
42960
  import { parseArgs } from "node:util";
42961
42961
  import { randomBytes as randomBytes6 } from "node:crypto";
42962
42962
  import { homedir as homedir14, hostname as hostname4 } from "node:os";
42963
- import { dirname as dirname11, join as join33 } from "node:path";
42963
+ import { dirname as dirname12, join as join34 } from "node:path";
42964
42964
  import { mkdirSync as mkdirSync11, writeSync as writeSync4 } from "node:fs";
42965
42965
 
42966
42966
  // packages/protocol/src/brand.ts
42967
42967
  var APP_NAME = "Centralu";
42968
- var APP_VERSION = "0.1.0-beta.21";
42968
+ var APP_VERSION = "0.1.0-beta.23";
42969
42969
  var APP_SLUG = "centralu";
42970
42970
  var CLIENT_INFO = { name: APP_SLUG, title: APP_NAME, version: APP_VERSION };
42971
42971
  var DATA_DIR = ".centralu";
@@ -43015,6 +43015,13 @@ function split(raw) {
43015
43015
  const pre2 = dash === -1 ? null : v.slice(dash + 1);
43016
43016
  return [core, pre2];
43017
43017
  }
43018
+ var LOCAL_BUILD = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*\.local\.[1-9]\d*$/;
43019
+ function isLocalBuildVersion(v) {
43020
+ return typeof v === "string" && v.length <= 64 && LOCAL_BUILD.test(v);
43021
+ }
43022
+ function keepsInstalledCopy(target, copyVersion) {
43023
+ return isLocalBuildVersion(copyVersion) && isNewerVersion(copyVersion, target);
43024
+ }
43018
43025
 
43019
43026
  // packages/protocol/src/agent-version.ts
43020
43027
  function parseCliVersion(text2) {
@@ -58020,7 +58027,12 @@ var ApprovalDetail = external_exports.discriminatedUnion("kind", [
58020
58027
  diffPreview: external_exports.string(),
58021
58028
  multi: external_exports.boolean().default(false)
58022
58029
  }),
58023
- external_exports.object({ kind: external_exports.literal("other"), raw: external_exports.string() }),
58030
+ /**
58031
+ * Anything else, as one text to read. `about` names a card the host raised that a window may know more about
58032
+ * (#665: `centralu_apply`, an agent applying the installed update); a window that does not know it, or predates
58033
+ * the field, reads `raw` as it always has, so the question is the whole of the card in every build.
58034
+ */
58035
+ external_exports.object({ kind: external_exports.literal("other"), raw: external_exports.string(), about: external_exports.string().optional() }),
58024
58036
  external_exports.object({
58025
58037
  kind: external_exports.literal("capability"),
58026
58038
  /** The app trying to use the capability — name is from the manifest */
@@ -58076,7 +58088,11 @@ var ApprovalDetail = external_exports.discriminatedUnion("kind", [
58076
58088
  /** One line for a person to read: the task, cut short */
58077
58089
  text: external_exports.string()
58078
58090
  }),
58079
- /** An ordinary session asks to check or install Centralu itself (#581). */
58091
+ /**
58092
+ * An ordinary session asks to check or install Centralu itself (#581). Applying the installed build (#665) is not an
58093
+ * action here: a released window parses events strictly and drops one whose enum it does not know, so that card
58094
+ * rides on `other` with `about: 'centralu_apply'`.
58095
+ */
58080
58096
  external_exports.object({
58081
58097
  kind: external_exports.literal("centralu_update"),
58082
58098
  action: external_exports.enum(["check", "install"])
@@ -58101,6 +58117,11 @@ var ApprovalDetail = external_exports.discriminatedUnion("kind", [
58101
58117
  machine: external_exports.string().optional()
58102
58118
  })
58103
58119
  ]);
58120
+ var CENTRALU_APPLY = "centralu_apply";
58121
+ var CENTRALU_APPLY_QUESTION = "Allow this session to apply the installed Centralu update? The window relaunches into it and running agents keep going. Right after, the new window switches the agent host to the new build: in any session, a Centralu tool call still running after 10 seconds (an agent waiting on its child agents, for one) is stopped and has to be tried again.";
58122
+ function isCentraluApply(d2) {
58123
+ return d2.kind === "other" && d2.about === CENTRALU_APPLY;
58124
+ }
58104
58125
  var ProjectConsent = external_exports.object({
58105
58126
  fromProjectId: external_exports.string(),
58106
58127
  fromName: external_exports.string(),
@@ -58537,7 +58558,15 @@ var UiPreferences = external_exports.object({
58537
58558
  */
58538
58559
  sessionTools: external_exports.boolean(),
58539
58560
  /** Running children per project: null uses preset defaults (auto unlimited, otherwise 4); 0 is unlimited. */
58540
- agentCeiling: external_exports.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable().optional()
58561
+ agentCeiling: external_exports.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable().optional(),
58562
+ /**
58563
+ * The sidebar groups the person folded (#666): the children of a session (`session:<id>`) and the
58564
+ * sessions of an app (`app:<key>`). Only folded groups are listed, so a group nobody touched is
58565
+ * open and an unknown or stale key does nothing. A way of looking, kept with the other screen
58566
+ * preferences so it survives a restart; the patch carries the whole list, since a list cannot be
58567
+ * patched by one entry.
58568
+ */
58569
+ foldedGroups: external_exports.array(external_exports.string().max(300)).max(500)
58541
58570
  });
58542
58571
  var UiPreferencesPatch = UiPreferences.partial();
58543
58572
  var DEFAULT_UI_PREFERENCES = {
@@ -58550,7 +58579,8 @@ var DEFAULT_UI_PREFERENCES = {
58550
58579
  codeFont: "",
58551
58580
  lineHeight: "normal",
58552
58581
  textSize: 1,
58553
- sessionTools: true
58582
+ sessionTools: true,
58583
+ foldedGroups: []
58554
58584
  };
58555
58585
  function parseUiPreferences(raw) {
58556
58586
  const out = { ...DEFAULT_UI_PREFERENCES };
@@ -58812,7 +58842,17 @@ var NormalizedEvent = external_exports.discriminatedUnion("type", [
58812
58842
  * only came in lists and in answers to the window that woke a session, so another window, or a session woken by the
58813
58843
  * orchestrator or an app, kept showing the old value.
58814
58844
  */
58815
- external_exports.object({ ...base, type: external_exports.literal("session_live"), live: external_exports.boolean(), reason: external_exports.enum(["asleep"]).optional() }),
58845
+ external_exports.object({
58846
+ ...base,
58847
+ type: external_exports.literal("session_live"),
58848
+ live: external_exports.boolean(),
58849
+ /**
58850
+ * Why the process went away, when `live` is false (#568, #648): `asleep` (an idle session put to sleep),
58851
+ * `restart_failed` (the host replaced the process and the new one did not come up), `crashed` (the process ended
58852
+ * on its own). Absent when `live` is true.
58853
+ */
58854
+ reason: external_exports.enum(["asleep", "restart_failed", "crashed"]).optional()
58855
+ }),
58816
58856
  external_exports.object({
58817
58857
  ...base,
58818
58858
  type: external_exports.literal("usage_update"),
@@ -58982,6 +59022,11 @@ var NormalizedEvent = external_exports.discriminatedUnion("type", [
58982
59022
  verbosity: external_exports.string().nullable(),
58983
59023
  /** Response speed. Follows the same snapshot rule as the three above — optional since old frames lack it */
58984
59024
  serviceTier: external_exports.string().nullable().optional(),
59025
+ /**
59026
+ * The permission preset after the change (#659). A snapshot like the four above; absent on frames that
59027
+ * predate it and on a tool's own switch, which never touches it. The receiving side keeps its value then.
59028
+ */
59029
+ permissionPreset: PermissionPreset.optional(),
58985
59030
  /**
58986
59031
  * Whose hand it was (#304). Absent means the orchestrator, the only author this event had before.
58987
59032
  *
@@ -58992,7 +59037,18 @@ var NormalizedEvent = external_exports.discriminatedUnion("type", [
58992
59037
  * From an adapter, the snapshot is the adapter's own view; the host applies only the fields that differ from what
58993
59038
  * the process was launched with, and re-sends its full snapshot.
58994
59039
  */
58995
- by: external_exports.enum(["orchestrator", "tool"]).optional()
59040
+ by: external_exports.enum(["orchestrator", "tool"]).optional(),
59041
+ /**
59042
+ * A client changed it through `agents.updateSettings` (#659). The caller already has the answer, but every other
59043
+ * window, linked machine and the person's own window (when an agent made the call) only learns it from this event.
59044
+ * It updates the values shown without the "Orchestrator changed" toast: nobody else's hand was involved, and the
59045
+ * caller's own toast states what it did.
59046
+ *
59047
+ * **A separate flag, not a third `by` word**: a build from before it parses this frame against a `by` enum without
59048
+ * the new word, and a frame that fails to parse is dropped whole. As a flag it is stripped as an unknown key, and
59049
+ * that build still updates the values (and raises one toast, as it did for any change not made by a tool).
59050
+ */
59051
+ person: external_exports.boolean().optional()
58996
59052
  }),
58997
59053
  external_exports.object({ ...base, type: external_exports.literal("history_synced"), added: external_exports.number() }),
58998
59054
  /** The session was deleted — the list must stay correct in other windows and after a reconnect too */
@@ -59052,7 +59108,13 @@ var NormalizedEvent = external_exports.discriminatedUnion("type", [
59052
59108
  type: external_exports.literal("background_tasks"),
59053
59109
  live: external_exports.array(BackgroundTask),
59054
59110
  ended: external_exports.array(BackgroundTask).optional(),
59055
- clearEnded: external_exports.boolean().optional()
59111
+ clearEnded: external_exports.boolean().optional(),
59112
+ /**
59113
+ * `live` is the tool's own full list of what runs in the process (#648): Claude's `background_tasks_changed`. Only
59114
+ * such a list settles what a process adopted from a previous host holds; a level built from what this host saw
59115
+ * (an ending, a `/loop` timer) does not.
59116
+ */
59117
+ snapshot: external_exports.boolean().optional()
59056
59118
  }),
59057
59119
  /**
59058
59120
  * The version of the agent CLI this session's process runs (#297): Claude's init message (`claude_code_version`), the
@@ -59080,6 +59142,18 @@ var NormalizedEvent = external_exports.discriminatedUnion("type", [
59080
59142
  * the long-running window the schedule was for.
59081
59143
  */
59082
59144
  external_exports.object({ ...appScoped, type: external_exports.literal("update_status"), status: UpdateStatus }),
59145
+ /**
59146
+ * An agent asked to apply the installed update (#665, `update_centralu` action "apply"): each window that hears this
59147
+ * makes the checks its "Apply now" makes and answers with `updates.answerApply`. Nothing relaunches yet; a window
59148
+ * the host picks is told so by `update_apply_go`. A replay of this event after the host stopped asking is answered
59149
+ * with no ticket, so it starts nothing.
59150
+ */
59151
+ external_exports.object({ ...appScoped, type: external_exports.literal("update_apply_request"), requestId: external_exports.string() }),
59152
+ /**
59153
+ * The one window holding `ticket` (from its `updates.answerApply`) relaunches now, through the same path as its
59154
+ * "Apply now" button, and reports with `updates.reportApply`. Every other window ignores it: only one relaunches.
59155
+ */
59156
+ external_exports.object({ ...appScoped, type: external_exports.literal("update_apply_go"), requestId: external_exports.string(), ticket: external_exports.string() }),
59083
59157
  /**
59084
59158
  * An external app's tool call finished (M4 A-4). Deliberately coarse: it never carries what
59085
59159
  * changed, only that the receiving side should refetch. An external app's state lives inside the
@@ -59486,7 +59560,13 @@ var UpdateSettingsParams = external_exports.object({
59486
59560
  verbosity: external_exports.string().nullable().optional(),
59487
59561
  /** Response speed. Same rule — which tiers are supported is stated by the model list */
59488
59562
  serviceTier: external_exports.string().nullable().optional(),
59489
- permissionPreset: PermissionPreset.optional()
59563
+ permissionPreset: PermissionPreset.optional(),
59564
+ /**
59565
+ * Whose hand makes the call (#659). Absent means a person's client, which is announced without a toast. The
59566
+ * orchestrator's settings tool on a linked machine sends `orchestrator`, so the machine broadcasts the change as the
59567
+ * orchestrator's and the window still toasts it. A host from before this field strips it, and then says nothing.
59568
+ */
59569
+ by: external_exports.literal("orchestrator").optional()
59490
59570
  });
59491
59571
  var SessionKind = external_exports.enum(["worker", "orchestrator", "coordinator"]);
59492
59572
  var SessionInfo = external_exports.object({
@@ -61158,6 +61238,24 @@ var RpcMethods = {
61158
61238
  params: external_exports.object({}),
61159
61239
  result: UpdateStatus
61160
61240
  },
61241
+ /**
61242
+ * A window's answer to `update_apply_request` (#665): whether its "Apply now" could relaunch into the installed build
61243
+ * right now, and why not. The first window that is ready gets a ticket; every other answer, and any answer after the
61244
+ * host stopped asking, gets null.
61245
+ */
61246
+ "updates.answerApply": {
61247
+ params: external_exports.object({ requestId: external_exports.string(), ready: external_exports.boolean(), reason: external_exports.string().optional(), version: external_exports.string().optional() }),
61248
+ result: external_exports.object({ ticket: external_exports.string().nullable() })
61249
+ },
61250
+ /**
61251
+ * The picked window's outcome (#665): `started` right before it relaunches, then with `error` when it did not, or
61252
+ * with neither once the shell accepted the relaunch. A relaunching window may close before that last report; the
61253
+ * host reads its connection ending as the relaunch only after `started`.
61254
+ */
61255
+ "updates.reportApply": {
61256
+ params: external_exports.object({ requestId: external_exports.string(), ticket: external_exports.string(), started: external_exports.boolean().optional(), error: external_exports.string().optional() }),
61257
+ result: external_exports.object({})
61258
+ },
61161
61259
  /**
61162
61260
  * Reads screen settings (UiPreferences).
61163
61261
  *
@@ -62711,6 +62809,10 @@ var HostServer = class _HostServer {
62711
62809
  const frame = `{"kind":"event","seq":${entry.seq},"event":${body}}`;
62712
62810
  for (const ws2 of this.clients) this.sendTo(ws2, frame);
62713
62811
  }
62812
+ /** The connections that said hello with the front door's token: windows, and a hub linked to this host. Never a bridge */
62813
+ get clientCount() {
62814
+ return this.clients.size;
62815
+ }
62714
62816
  /** This host lifetime's id — sent in every `hello_ok` (#82) */
62715
62817
  get streamEpoch() {
62716
62818
  return this.log.streamEpoch;
@@ -62908,17 +63010,17 @@ function portInUseMessage(port2, servedBy) {
62908
63010
  var VIEW_HANDOVER_KEY = "views.handover";
62909
63011
  var VIEW_HANDOVER_MAX_AGE_MS = 10 * 6e4;
62910
63012
  function recordViewHandover(settings, views2, inline, now = Date.now()) {
62911
- const open5 = views2.list().map((v) => {
63013
+ const open6 = views2.list().map((v) => {
62912
63014
  const owner = inline?.owner(v.id);
62913
63015
  return owner ? { ...v, inline: { sessionId: owner.sessionId, callId: owner.callId, tool: owner.tool } } : v;
62914
63016
  });
62915
- if (open5.length === 0) {
63017
+ if (open6.length === 0) {
62916
63018
  settings.deleteAppSetting(VIEW_HANDOVER_KEY);
62917
63019
  return 0;
62918
63020
  }
62919
- const record2 = { at: now, views: open5 };
63021
+ const record2 = { at: now, views: open6 };
62920
63022
  settings.setAppSetting(VIEW_HANDOVER_KEY, JSON.stringify(record2));
62921
- return open5.length;
63023
+ return open6.length;
62922
63024
  }
62923
63025
  function restoreViewHandover(settings, views2, inline, now = Date.now()) {
62924
63026
  const raw = settings.appSetting(VIEW_HANDOVER_KEY);
@@ -63027,15 +63129,15 @@ function buildViewCsp(csp) {
63027
63129
  const frame = sanitizeDomains(csp?.frameDomains);
63028
63130
  const base2 = sanitizeDomains(csp?.baseUriDomains);
63029
63131
  const r = resource.kept.join(" ");
63030
- const join34 = (...parts) => parts.filter(Boolean).join(" ");
63132
+ const join35 = (...parts) => parts.filter(Boolean).join(" ");
63031
63133
  const policy = [
63032
63134
  "default-src 'none'",
63033
- `script-src ${join34("'unsafe-inline'", r)}`,
63034
- `style-src ${join34("'unsafe-inline'", r)}`,
63035
- `img-src ${join34("data: blob:", r)}`,
63036
- `font-src ${join34("data:", r)}`,
63037
- `media-src ${join34("data: blob:", r)}`,
63038
- `worker-src ${join34("blob:", r)}`,
63135
+ `script-src ${join35("'unsafe-inline'", r)}`,
63136
+ `style-src ${join35("'unsafe-inline'", r)}`,
63137
+ `img-src ${join35("data: blob:", r)}`,
63138
+ `font-src ${join35("data:", r)}`,
63139
+ `media-src ${join35("data: blob:", r)}`,
63140
+ `worker-src ${join35("blob:", r)}`,
63039
63141
  `connect-src ${connect2.kept.length ? connect2.kept.join(" ") : "'none'"}`,
63040
63142
  `frame-src ${frame.kept.length ? frame.kept.join(" ") : "'none'"}`,
63041
63143
  "form-action 'none'",
@@ -63639,9 +63741,9 @@ import { join as join15 } from "node:path";
63639
63741
  import { lstatSync as lstatSync2, watch } from "node:fs";
63640
63742
 
63641
63743
  // packages/agent-host/src/dev-services/fs.ts
63642
- import { spawn } from "node:child_process";
63744
+ import { spawn as spawn2 } from "node:child_process";
63643
63745
  import { constants as constants3 } from "node:fs";
63644
- import { lstat as lstat2, mkdir, open, readdir, readlink, realpath as realpath2, rename, rm, stat as stat2, writeFile } from "node:fs/promises";
63746
+ import { lstat as lstat2, mkdir, open as open2, readdir, readlink, realpath as realpath2, rename, rm, stat as stat2, writeFile } from "node:fs/promises";
63645
63747
  import { basename, dirname as dirname2, extname, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve, sep as sep2 } from "node:path";
63646
63748
 
63647
63749
  // packages/agent-host/src/dev-services/path-guard.ts
@@ -63776,7 +63878,8 @@ function isMissingFsPath(error51) {
63776
63878
  }
63777
63879
 
63778
63880
  // packages/agent-host/src/dev-services/git-exec.ts
63779
- import { execFile } from "node:child_process";
63881
+ import { execFile, spawn } from "node:child_process";
63882
+ import { open } from "node:fs/promises";
63780
63883
  import { devNull } from "node:os";
63781
63884
  import { promisify } from "node:util";
63782
63885
 
@@ -64002,6 +64105,40 @@ async function gitArgv(cwd, args, allowRepoPrograms = false) {
64002
64105
  }
64003
64106
  async function runGit(cwd, args, opts = {}) {
64004
64107
  const argv = await gitArgv(cwd, args, opts.allowRepoPrograms);
64108
+ if (opts.outputFile) {
64109
+ const output = await open(opts.outputFile, "w", 384);
64110
+ try {
64111
+ await new Promise((resolve12, reject) => {
64112
+ const child = spawn(programPath("git"), argv, {
64113
+ cwd,
64114
+ stdio: ["pipe", output.fd, "pipe"],
64115
+ timeout: opts.timeout ?? DEFAULTS.timeout,
64116
+ ...opts.env ? { env: { ...process.env, ...opts.env } } : {}
64117
+ });
64118
+ let stderr = "";
64119
+ let overflow = false;
64120
+ child.stderr.on("data", (chunk) => {
64121
+ if (overflow) return;
64122
+ stderr += chunk.toString();
64123
+ if (Buffer.byteLength(stderr) > (opts.maxBuffer ?? DEFAULTS.maxBuffer)) {
64124
+ overflow = true;
64125
+ child.kill();
64126
+ }
64127
+ });
64128
+ child.on("error", reject);
64129
+ child.on("close", (code2, signal) => {
64130
+ if (code2 === 0 && !overflow) resolve12();
64131
+ else reject(Object.assign(new Error(overflow ? "Git stderr exceeded maxBuffer" : stderr || `Git exited with ${code2 ?? signal}`), { code: code2, stderr }));
64132
+ });
64133
+ child.stdin.on("error", () => {
64134
+ });
64135
+ child.stdin.end(opts.input);
64136
+ });
64137
+ return "";
64138
+ } finally {
64139
+ await output.close();
64140
+ }
64141
+ }
64005
64142
  const run = exec(programPath("git"), argv, {
64006
64143
  cwd,
64007
64144
  timeout: opts.timeout ?? DEFAULTS.timeout,
@@ -64052,6 +64189,13 @@ function installedCopyPath(platform = HOST_PLATFORM, env = process.env, home = h
64052
64189
  }
64053
64190
  return posix2.join(home, ".local/share/applications", `${APP_SLUG}.desktop`);
64054
64191
  }
64192
+ function installedCopyVersionSource(copy, platform = HOST_PLATFORM) {
64193
+ if (platform === "darwin") {
64194
+ return { kind: "command", file: "/usr/bin/plutil", args: ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", posix2.join(copy, "Contents", "Info.plist")] };
64195
+ }
64196
+ if (platform === "win32") return { kind: "file", name: "centralu-version.txt" };
64197
+ return null;
64198
+ }
64055
64199
 
64056
64200
  // packages/agent-host/src/dev-services/fs.ts
64057
64201
  var MAX_TEXT = 2e6;
@@ -64162,7 +64306,7 @@ async function readTextFile(root3, rel) {
64162
64306
  const file2 = await resolveExisting(root3, rel);
64163
64307
  const pathInfo = await stat2(file2, { bigint: true });
64164
64308
  if (!pathInfo.isFile()) fail2("Path is not a regular file");
64165
- const handle = await open(file2, READ_TEXT_FLAGS);
64309
+ const handle = await open2(file2, READ_TEXT_FLAGS);
64166
64310
  try {
64167
64311
  const opened = await handle.stat({ bigint: true });
64168
64312
  if (!opened.isFile()) fail2("Path is not a regular file");
@@ -64228,7 +64372,7 @@ async function copyTree(src, dst, clone2 = cloneTree) {
64228
64372
  async function cloneTree(src, dst) {
64229
64373
  if (!clonesFiles()) return false;
64230
64374
  return new Promise((done) => {
64231
- const p = spawn("/bin/cp", ["-Rc", src, dst], { stdio: "ignore" });
64375
+ const p = spawn2("/bin/cp", ["-Rc", src, dst], { stdio: "ignore" });
64232
64376
  p.on("error", () => done(false));
64233
64377
  p.on("close", (code2) => done(code2 === 0));
64234
64378
  });
@@ -64385,7 +64529,7 @@ function isSymlink(path2) {
64385
64529
  }
64386
64530
 
64387
64531
  // packages/agent-host/src/apps/external/app-process.ts
64388
- import { spawn as spawn2 } from "node:child_process";
64532
+ import { spawn as spawn3 } from "node:child_process";
64389
64533
  import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync, openSync as openSync2, renameSync as renameSync3, statSync as statSync3, writeSync as writeSync3 } from "node:fs";
64390
64534
  import { dirname as dirname3 } from "node:path";
64391
64535
 
@@ -79941,7 +80085,7 @@ var AppProcess = class _AppProcess {
79941
80085
  log.note(`starting: ${spec.command} ${spec.args.join(" ")}`);
79942
80086
  const launch2 = resolveCommand(spec.command, spec.env);
79943
80087
  const spawnedAt = Date.now();
79944
- const child = spawn2(launch2.command, [...launch2.args, ...spec.args], {
80088
+ const child = spawn3(launch2.command, [...launch2.args, ...spec.args], {
79945
80089
  cwd: spec.cwd,
79946
80090
  env: spec.env,
79947
80091
  /*
@@ -94045,7 +94189,7 @@ var RUN_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
94045
94189
  var FAILURES_KEPT = 20;
94046
94190
  var SUMMARY_CHARS = 200;
94047
94191
  function announcingLedger(inner, announce) {
94048
- const open5 = /* @__PURE__ */ new Map();
94192
+ const open6 = /* @__PURE__ */ new Map();
94049
94193
  const tell = (apps) => {
94050
94194
  for (const a of apps ?? []) announce(a);
94051
94195
  };
@@ -94054,20 +94198,20 @@ function announcingLedger(inner, announce) {
94054
94198
  begin(row) {
94055
94199
  inner.begin(row);
94056
94200
  const apps = [{ projectId: row.projectId, appId: row.appId }];
94057
- for (const up2 of row.parentRunId ? open5.get(row.parentRunId) ?? [] : []) {
94201
+ for (const up2 of row.parentRunId ? open6.get(row.parentRunId) ?? [] : []) {
94058
94202
  if (!apps.some((a) => a.projectId === up2.projectId && a.appId === up2.appId)) apps.push(up2);
94059
94203
  }
94060
- open5.set(row.id, apps);
94204
+ open6.set(row.id, apps);
94061
94205
  tell(apps);
94062
94206
  },
94063
94207
  link(id2, sessionId) {
94064
94208
  inner.link(id2, sessionId);
94065
- tell(open5.get(id2));
94209
+ tell(open6.get(id2));
94066
94210
  },
94067
94211
  end(id2, e) {
94068
94212
  inner.end(id2, e);
94069
- tell(open5.get(id2));
94070
- open5.delete(id2);
94213
+ tell(open6.get(id2));
94214
+ open6.delete(id2);
94071
94215
  }
94072
94216
  };
94073
94217
  }
@@ -96600,8 +96744,8 @@ var ExternalApps = class {
96600
96744
  return { runId, status, result, error: error51, durationMs };
96601
96745
  };
96602
96746
  if (!e.manifest) return done("rejected", null, `This app's manifest is invalid: ${e.error}`);
96603
- const open5 = caller.kind === "session" && this.sessionBoundaryOpen(caller.sessionId);
96604
- if (!e.scope.trusted && !open5) return done("rejected", null, "This app's project is not trusted, so Centralu does not call its apps");
96747
+ const open6 = caller.kind === "session" && this.sessionBoundaryOpen(caller.sessionId);
96748
+ if (!e.scope.trusted && !open6) return done("rejected", null, "This app's project is not trusted, so Centralu does not call its apps");
96605
96749
  const held3 = this.held(e);
96606
96750
  if (held3) return done("rejected", null, held3);
96607
96751
  if (e.life.gaveUp) return done("rejected", null, `This app stopped after failing ${this.timing.maxFailures} times in a row`);
@@ -96649,7 +96793,7 @@ var ExternalApps = class {
96649
96793
  abort.abort();
96650
96794
  for (const sig of upstream) sig.removeEventListener("abort", onUp);
96651
96795
  }
96652
- }, open5);
96796
+ }, open6);
96653
96797
  } catch (err) {
96654
96798
  return done("error", null, err.message);
96655
96799
  }
@@ -97960,9 +98104,9 @@ var InlineViews = class {
97960
98104
  * just opened is never the one closed.
97961
98105
  */
97962
98106
  capLive(sessionId, keep) {
97963
- const open5 = [...this.bySession.get(sessionId)?.values() ?? []].filter((x) => x.instanceId).sort((a, b2) => a.openedAt - b2.openedAt);
98107
+ const open6 = [...this.bySession.get(sessionId)?.values() ?? []].filter((x) => x.instanceId).sort((a, b2) => a.openedAt - b2.openedAt);
97964
98108
  const n = this.limits.livePerSession;
97965
- for (const old of open5.slice(0, Math.max(0, open5.length - n))) {
98109
+ for (const old of open6.slice(0, Math.max(0, open6.length - n))) {
97966
98110
  if (old === keep) continue;
97967
98111
  this.shut(old);
97968
98112
  this.deps.emit({
@@ -98172,8 +98316,9 @@ var OriginPorts = class {
98172
98316
 
98173
98317
  // packages/agent-host/src/dev-services/worktree-recovery.ts
98174
98318
  import { randomUUID as randomUUID6 } from "node:crypto";
98175
- import { existsSync as existsSync13 } from "node:fs";
98176
- import { chmod, lstat as lstat3, mkdir as mkdir2, readFile, readdir as readdir2, realpath as realpath3, rm as rm2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
98319
+ import { createReadStream, existsSync as existsSync13 } from "node:fs";
98320
+ import { chmod, lstat as lstat3, mkdir as mkdir2, mkdtemp, readFile, readdir as readdir2, realpath as realpath3, rename as rename2, rm as rm2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
98321
+ import { tmpdir as tmpdir2 } from "node:os";
98177
98322
  import { join as join16, resolve as resolve2 } from "node:path";
98178
98323
  var WORKTREE_RECOVERY_MAX_BYTES = 256 * 1024 * 1024;
98179
98324
  var RECOVERY_TIMEOUT = 12e4;
@@ -98181,8 +98326,70 @@ function worktreeRecoveryDir(sessionId) {
98181
98326
  if (!isSessionId(sessionId)) throw new Error("Invalid session id for worktree recovery");
98182
98327
  return join16(dataRoot(), "worktree-recovery", sessionId);
98183
98328
  }
98184
- async function clearWorktreeRecovery(sessionId) {
98185
- await rm2(worktreeRecoveryDir(sessionId), { recursive: true, force: true });
98329
+ async function stageWorktreeRecoveryRemoval(sessionId) {
98330
+ const dir = worktreeRecoveryDir(sessionId);
98331
+ if (!existsSync13(dir)) return { commit: async () => {
98332
+ }, abort: async () => {
98333
+ } };
98334
+ const staged = `${dir}${PURGING}${randomUUID6()}`;
98335
+ await rename2(dir, staged);
98336
+ return {
98337
+ commit: () => rm2(staged, { recursive: true, force: true, maxRetries: 3 }),
98338
+ abort: () => rename2(staged, dir)
98339
+ };
98340
+ }
98341
+ var PURGING = ".purging-";
98342
+ async function repairWorktreeRecoveryRemovals(trashed) {
98343
+ const root3 = join16(dataRoot(), "worktree-recovery");
98344
+ if (!existsSync13(root3)) return;
98345
+ for (const name of await readdir2(root3)) {
98346
+ const at2 = name.indexOf(PURGING);
98347
+ if (at2 === -1) continue;
98348
+ const sessionId = name.slice(0, at2);
98349
+ if (!isSessionId(sessionId)) continue;
98350
+ const staged = join16(root3, name);
98351
+ try {
98352
+ if (!trashed(sessionId)) {
98353
+ await rm2(staged, { recursive: true, force: true, maxRetries: 3 });
98354
+ continue;
98355
+ }
98356
+ const dir = join16(root3, sessionId);
98357
+ if (!existsSync13(dir)) {
98358
+ await rename2(staged, dir);
98359
+ continue;
98360
+ }
98361
+ for (const snapshot2 of await readdir2(staged)) {
98362
+ if (!existsSync13(join16(dir, snapshot2))) await rename2(join16(staged, snapshot2), join16(dir, snapshot2));
98363
+ }
98364
+ await rm2(staged, { recursive: true, force: true, maxRetries: 3 });
98365
+ } catch (e) {
98366
+ console.error(`[trash] recovery folder ${name} left by an interrupted purge was not repaired, retried at the next start: ${e.message}`);
98367
+ }
98368
+ }
98369
+ }
98370
+ var MARK = "refs/centralu/recovered/";
98371
+ async function clearRecoveredBranchMark(repo, branch) {
98372
+ if (!branch.startsWith("recovered/")) return;
98373
+ await runGit(repo, ["update-ref", "-d", `${MARK}${branch}`]);
98374
+ }
98375
+ async function* gitRecords(repo, args, input) {
98376
+ const dir = await mkdtemp(join16(tmpdir2(), "cc-recovery-git-"));
98377
+ const file2 = join16(dir, "records");
98378
+ try {
98379
+ await runGit(repo, args, { outputFile: file2, timeout: RECOVERY_TIMEOUT, input });
98380
+ let pending = "";
98381
+ for await (const chunk of createReadStream(file2, { encoding: "utf8" })) {
98382
+ pending += chunk;
98383
+ let end;
98384
+ while ((end = pending.indexOf("\0")) !== -1) {
98385
+ yield pending.slice(0, end);
98386
+ pending = pending.slice(end + 1);
98387
+ }
98388
+ }
98389
+ if (pending) yield pending;
98390
+ } finally {
98391
+ await rm2(dir, { recursive: true, force: true });
98392
+ }
98186
98393
  }
98187
98394
  async function saveWorktreeRecovery(sessionId, cwd, trunk, branch) {
98188
98395
  const plans = [];
@@ -98193,7 +98400,7 @@ async function saveWorktreeRecovery(sessionId, cwd, trunk, branch) {
98193
98400
  throw new Error(`Worktree recovery exceeds the 256 MiB input limit (tracked and non-ignored untracked files plus staged changes, including submodules). No files were staged or removed; save the work elsewhere before deleting.`);
98194
98401
  }
98195
98402
  };
98196
- const prepare = async (repo, path2) => {
98403
+ const prepare = async (repo, path2, gitlinks = []) => {
98197
98404
  const tip = (await runGit(repo, ["rev-parse", "--verify", "HEAD"])).trim();
98198
98405
  const refs = ["HEAD"];
98199
98406
  if (!path2 && branch) {
@@ -98201,20 +98408,85 @@ async function saveWorktreeRecovery(sessionId, cwd, trunk, branch) {
98201
98408
  if (await runGit(repo, ["rev-parse", "--verify", ref]).then(() => true).catch(() => false)) refs.push(ref);
98202
98409
  } else if (path2) {
98203
98410
  refs.push(...(await runGit(repo, ["for-each-ref", "--format=%(refname)", "refs/heads/"])).trim().split("\n").filter(Boolean));
98411
+ if (gitlinks.length) {
98412
+ const available = await runGit(repo, ["cat-file", "--batch-check=%(objectname) %(objecttype)"], {
98413
+ input: gitlinks.map((oid) => `${oid}^{commit}`).join("\n") + "\n",
98414
+ timeout: RECOVERY_TIMEOUT,
98415
+ // Older Git ignores the lazy-fetch switch; disallow transports there too.
98416
+ env: { GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "" }
98417
+ });
98418
+ for (const row of available.trim().split("\n")) {
98419
+ const [oid, type] = row.split(" ");
98420
+ if (type === "commit") refs.push(oid);
98421
+ }
98422
+ }
98204
98423
  }
98205
98424
  const baselineOid = !path2 ? await runGit(repo, ["rev-parse", "--verify", `${trunk}^{commit}`]).then((s) => s.trim()).catch(() => null) : null;
98206
- const exclude = path2 ? ["--not", "--remotes"] : baselineOid ? ["--not", baselineOid] : [];
98207
- const commits = Number((await runGit(repo, ["rev-list", "--count", ...refs, ...exclude])).trim());
98425
+ const exclude = path2 ? (await runGit(repo, ["for-each-ref", "--format=^%(objectname)", "refs/remotes/"])).trim().split("\n").filter(Boolean) : baselineOid ? [`^${baselineOid}`] : [];
98426
+ const revisions = [...refs, ...exclude].join("\n") + "\n";
98427
+ const commits = Number((await runGit(repo, ["rev-list", "--count", "--stdin"], { input: revisions, timeout: RECOVERY_TIMEOUT })).trim());
98208
98428
  plans.push({ repo, path: path2, tip, refs, commits });
98209
98429
  const stagedNames = new Set((await runGit(repo, ["diff", "--cached", "--name-only", "-z", "--no-renames", tip], { timeout: RECOVERY_TIMEOUT })).split("\0"));
98210
98430
  const modules = [];
98431
+ const moduleCommits = /* @__PURE__ */ new Map();
98432
+ const keepGitlink = (rel, oid) => {
98433
+ modules.push(rel);
98434
+ const tips = moduleCommits.get(rel) ?? /* @__PURE__ */ new Set();
98435
+ tips.add(oid);
98436
+ moduleCommits.set(rel, tips);
98437
+ };
98438
+ const manifests = /* @__PURE__ */ new Set();
98439
+ const keepManifest = (rel, oid) => {
98440
+ if (rel === ".gitmodules" && !/^0+$/.test(oid)) manifests.add(oid);
98441
+ };
98442
+ let change = null;
98443
+ for await (const row of gitRecords(repo, ["log", "--raw", "-z", "--no-renames", "--ignore-submodules=none", "--no-abbrev", "--format=", "--root", "--diff-merges=separate", "--stdin"], revisions)) {
98444
+ if (change) {
98445
+ const [, oldMode, newMode, oldOid, newOid] = change;
98446
+ if (oldMode === "160000") keepGitlink(row, oldOid);
98447
+ if (newMode === "160000") keepGitlink(row, newOid);
98448
+ keepManifest(row, oldOid);
98449
+ keepManifest(row, newOid);
98450
+ change = null;
98451
+ } else change = row.match(/^\n*:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) [A-Z]$/);
98452
+ }
98453
+ for await (const row of gitRecords(repo, ["ls-tree", "-r", "-z", "HEAD"])) {
98454
+ const tab = row.indexOf(" ");
98455
+ const [mode, , oid] = row.slice(0, tab).split(" ");
98456
+ const rel = row.slice(tab + 1);
98457
+ if (mode === "160000") keepGitlink(rel, oid);
98458
+ keepManifest(rel, oid);
98459
+ }
98211
98460
  const stagedObjects = [];
98212
- for (const row of (await runGit(repo, ["ls-files", "--stage", "-z"])).split("\0")) {
98461
+ for await (const row of gitRecords(repo, ["ls-files", "--stage", "-z"])) {
98213
98462
  if (!row) continue;
98214
98463
  const tab = row.indexOf(" ");
98215
98464
  const rel = row.slice(tab + 1);
98216
- if (row.startsWith("160000 ")) modules.push(rel);
98217
- else if (stagedNames.has(rel)) stagedObjects.push(row.split(" ")[1]);
98465
+ const oid = row.split(" ")[1];
98466
+ if (row.startsWith("160000 ")) keepGitlink(rel, oid);
98467
+ else if (stagedNames.has(rel)) stagedObjects.push(oid);
98468
+ keepManifest(rel, oid);
98469
+ }
98470
+ const modulePaths = /* @__PURE__ */ new Map();
98471
+ for (const oid of manifests) {
98472
+ const config2 = await runGit(repo, ["config", "--blob", oid, "-z", "--get-regexp", "^submodule\\..*\\.path$"]).catch((e) => {
98473
+ if (e.code === 1) return "";
98474
+ throw e;
98475
+ });
98476
+ for (const row of config2.split("\0").filter(Boolean)) {
98477
+ const newline = row.indexOf("\n");
98478
+ const name = row.slice("submodule.".length, newline - ".path".length);
98479
+ const paths = modulePaths.get(name) ?? /* @__PURE__ */ new Set();
98480
+ paths.add(row.slice(newline + 1));
98481
+ modulePaths.set(name, paths);
98482
+ }
98483
+ }
98484
+ for (const paths of modulePaths.values()) {
98485
+ const tips = new Set([...paths].flatMap((rel) => [...moduleCommits.get(rel) ?? []]));
98486
+ for (const rel of paths) {
98487
+ modules.push(rel);
98488
+ moduleCommits.set(rel, /* @__PURE__ */ new Set([...moduleCommits.get(rel) ?? [], ...tips]));
98489
+ }
98218
98490
  }
98219
98491
  if (stagedObjects.length) {
98220
98492
  const sizes = await runGit(repo, ["cat-file", "--batch-check=%(objectsize)"], { input: stagedObjects.join("\n") + "\n" });
@@ -98236,7 +98508,7 @@ async function saveWorktreeRecovery(sessionId, cwd, trunk, branch) {
98236
98508
  await assertExistingPath(repo, rel).catch((e) => {
98237
98509
  if (existsSync13(join16(repo, rel))) throw e;
98238
98510
  });
98239
- if (existsSync13(join16(repo, rel, ".git"))) await prepare(join16(repo, rel), path2 ? `${path2}/${rel}` : rel);
98511
+ if (existsSync13(join16(repo, rel, ".git"))) await prepare(join16(repo, rel), path2 ? `${path2}/${rel}` : rel, [...moduleCommits.get(rel) ?? []]);
98240
98512
  }
98241
98513
  };
98242
98514
  await prepare(cwd, "");
@@ -98257,7 +98529,26 @@ async function saveWorktreeRecovery(sessionId, cwd, trunk, branch) {
98257
98529
  const entry = { path: path2, tip, refs, patch: false, staged: false, bundle: commits > 0 };
98258
98530
  snapshot2.repositories.push(entry);
98259
98531
  snapshot2.commits += commits;
98260
- if (entry.bundle) await runGit(repo, ["bundle", "create", join16(dir, `${n}.bundle`), ...refs], { timeout: RECOVERY_TIMEOUT });
98532
+ if (entry.bundle) {
98533
+ const temporaryRefs = [];
98534
+ const updates2 = [];
98535
+ try {
98536
+ entry.refs = [];
98537
+ for (const ref of refs) {
98538
+ if (ref === "HEAD" || ref.startsWith("refs/")) entry.refs.push(ref);
98539
+ else {
98540
+ const name = `refs/centralu-recovery/${snapshotId}/gitlink-${temporaryRefs.length}`;
98541
+ temporaryRefs.push(name);
98542
+ updates2.push(`create ${name} ${ref}`);
98543
+ entry.refs.push(name);
98544
+ }
98545
+ }
98546
+ if (updates2.length) await runGit(repo, ["update-ref", "--stdin"], { input: updates2.join("\n") + "\n", timeout: RECOVERY_TIMEOUT });
98547
+ await runGit(repo, ["bundle", "create", join16(dir, `${n}.bundle`), "--stdin"], { input: entry.refs.join("\n") + "\n", timeout: RECOVERY_TIMEOUT });
98548
+ } finally {
98549
+ if (temporaryRefs.length) await runGit(repo, ["update-ref", "--stdin"], { input: temporaryRefs.map((ref) => `delete ${ref}`).join("\n") + "\n", timeout: RECOVERY_TIMEOUT });
98550
+ }
98551
+ }
98261
98552
  entry.staged = await patchFile(repo, tip, join16(dir, `${n}.staged.patch`));
98262
98553
  const stagedNames = await runGit(repo, ["diff", "--cached", "--name-only", "-z", "--no-renames", tip], { timeout: RECOVERY_TIMEOUT });
98263
98554
  const env = { GIT_INDEX_FILE: join16(dir, `${n}.index`) };
@@ -98292,28 +98583,83 @@ async function restoreWorktreeRecovery(sessionId, repo, path2, branch, recovery,
98292
98583
  const dir = join16(worktreeRecoveryDir(sessionId), recovery.snapshot);
98293
98584
  const snapshot2 = JSON.parse(await readFile(join16(dir, "snapshot.json"), "utf8"));
98294
98585
  const root3 = snapshot2.repositories[0];
98586
+ let extraNotice = "";
98587
+ if (root3.bundle) {
98588
+ const heads = (await runGit(repo, ["bundle", "list-heads", join16(dir, "0.bundle")])).trim().split("\n");
98589
+ for (const row of heads) {
98590
+ const [tip, ref] = row.split(" ");
98591
+ if (!tip || !ref?.startsWith("refs/heads/") || tip === recovery.tip) continue;
98592
+ const base2 = `recovered/${sessionId}-stored-branch`;
98593
+ let recovered = base2;
98594
+ for (let suffix = 1; ; suffix++) {
98595
+ const existing = await runGit(repo, ["rev-parse", "--verify", `refs/heads/${recovered}`]).then((s) => s.trim()).catch(() => null);
98596
+ if (existing === tip) break;
98597
+ if (!existing) {
98598
+ await runGit(repo, ["fetch", join16(dir, "0.bundle"), `${ref}:refs/heads/${recovered}`], { allowRepoPrograms: trust.trusted === true });
98599
+ break;
98600
+ }
98601
+ recovered = `${base2}-${suffix}`;
98602
+ }
98603
+ extraNotice += `Stored branch ${ref.slice("refs/heads/".length)} recovered as ${recovered}. `;
98604
+ }
98605
+ }
98295
98606
  if (!existsSync13(path2)) {
98296
- if (await runGit(repo, ["show-ref", "--verify", `refs/heads/${branch}`]).then(() => true).catch(() => false)) {
98297
- branch = `recovered/${sessionId}`;
98607
+ await rm2(join16(dir, "patches-applied"), { force: true });
98608
+ const tipOf = (ref) => runGit(repo, ["rev-parse", "--verify", ref]).then((s) => s.trim()).catch(() => null);
98609
+ const createdBy = (ref) => runGit(repo, ["log", "-g", "--format=%gs", ref, "--"]).then((s) => s.trim().split("\n").at(-1) ?? "").catch(() => "");
98610
+ const ours = async (name) => {
98611
+ const existing = await tipOf(`refs/heads/${name}`);
98612
+ if (!existing) return null;
98613
+ if (existing !== recovery.tip || await tipOf(`${MARK}${name}`) !== recovery.tip) return false;
98614
+ const made = await createdBy(`${MARK}${name}`);
98615
+ if (!made.startsWith("centralu recovery ") || made !== await createdBy(`refs/heads/${name}`)) return false;
98616
+ return (await runGit(repo, ["for-each-ref", "--format=%(worktreepath)", `refs/heads/${name}`])).trim() === "";
98617
+ };
98618
+ let create = true;
98619
+ if (await tipOf(`refs/heads/${branch}`)) {
98620
+ const base2 = `recovered/${sessionId}`;
98621
+ branch = base2;
98622
+ for (let suffix = 1; ; suffix++) {
98623
+ const state = await ours(branch);
98624
+ if (state !== false) {
98625
+ create = state === null;
98626
+ break;
98627
+ }
98628
+ branch = `${base2}-${suffix}`;
98629
+ }
98298
98630
  }
98299
98631
  if (root3.bundle) await runGit(repo, ["fetch", join16(dir, "0.bundle"), ...root3.refs ?? ["HEAD"]], { allowRepoPrograms: trust.trusted === true });
98300
- await runGit(repo, ["worktree", "add", "-b", branch, path2, recovery.tip], { allowRepoPrograms: trust.trusted === true });
98632
+ if (create && branch.startsWith("recovered/")) {
98633
+ const made = `centralu recovery ${randomUUID6()}`;
98634
+ await runGit(repo, ["update-ref", "--create-reflog", "-m", made, `${MARK}${branch}`, recovery.tip]);
98635
+ await runGit(repo, ["update-ref", "--create-reflog", "-m", made, `refs/heads/${branch}`, recovery.tip, ""]);
98636
+ create = false;
98637
+ }
98638
+ await runGit(repo, ["worktree", "add", ...create ? ["-b", branch, path2, recovery.tip] : [path2, branch]], { allowRepoPrograms: trust.trusted === true });
98301
98639
  if (root3.patch || root3.staged) {
98302
98640
  try {
98303
98641
  if (root3.staged) await runGit(path2, ["apply", "--cached", "--binary", join16(dir, "0.staged.patch")]);
98304
98642
  if (root3.patch) await runGit(path2, ["apply", "--binary", join16(dir, "0.patch")]);
98643
+ await writeFile2(join16(dir, "patches-applied"), await realpath3(path2), { mode: 384 });
98305
98644
  } catch {
98306
- return { branch, notice: `Restored branch ${branch}. Patches could not all be applied. Recovery files remain at ${dir}; apply numbered .patch files manually with git apply and .staged.patch files with git apply --cached.` };
98645
+ return { branch, notice: `Restored branch ${branch}. ${extraNotice}Patches could not all be applied. Recovery files remain at ${dir}; apply numbered .patch files manually with git apply and .staged.patch files with git apply --cached.` };
98307
98646
  }
98308
98647
  }
98309
98648
  } else {
98310
98649
  const common = async (cwd) => realpath3(resolve2(cwd, (await runGit(cwd, ["rev-parse", "--git-common-dir"])).trim()));
98311
98650
  if (await common(path2) !== await common(repo)) throw new Error(`The worktree path belongs to another repository. Recovery files remain at ${dir}`);
98312
98651
  branch = (await runGit(path2, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
98652
+ if (root3.patch || root3.staged) {
98653
+ const appliedTo = await readFile(join16(dir, "patches-applied"), "utf8").catch((e) => {
98654
+ if (e.code === "ENOENT") return null;
98655
+ throw e;
98656
+ });
98657
+ extraNotice += appliedTo === await realpath3(path2) ? "Saved patches were applied during the previous restore attempt; they were not reapplied on this retry. " : "The worktree already existed; saved patches were not applied on this retry and may still need manual recovery. ";
98658
+ }
98313
98659
  }
98314
98660
  return {
98315
98661
  branch,
98316
- notice: `Restored branch ${branch}. Recovery files remain at ${dir}: ${recovery.changedFiles} uncommitted files, ${recovery.commits} commits absent from the trunk or submodule remotes. Numbered .patch files can be applied with git apply; .bundle files list their saved refs with git bundle list-heads and can be fetched with git fetch <bundle> <ref>:<branch>. snapshot.json names each repository and its refs. Submodule recovery is manual. These files are removed when this session is deleted for good.`
98662
+ notice: `Restored branch ${branch}. ${extraNotice}Recovery files remain at ${dir}: ${recovery.changedFiles} uncommitted files, ${recovery.commits} commits absent from the trunk or submodule remotes. Numbered .patch files can be applied with git apply; .bundle files list their saved refs with git bundle list-heads and can be fetched with git fetch <bundle> <ref>:<branch>. snapshot.json names each repository and its refs. Submodule recovery is manual. These files are removed when this session is deleted for good.`
98317
98663
  };
98318
98664
  }
98319
98665
 
@@ -98406,13 +98752,23 @@ and approved skills, and whether every session can look at its own project), App
98406
98752
  Shortcuts (the shortcut list), and Updates.`,
98407
98753
  updates: `# Updates
98408
98754
  Check from Settings \u2192 Updates. It reports a new version based on the npm registry, and the person
98409
- clicks to install it. The check for a new version runs on startup and every six hours, and it can
98410
- be turned off.
98755
+ clicks to install it. With automatic checking on, the check runs on startup, every six hours, when
98756
+ Updates opens, and on focus when the last check is over an hour old. With it off, use Check now.
98757
+ Ordinary checks follow npm, including dist-tag rollbacks. Installing reuses a successful check
98758
+ from the last minute or checks again. Only while installing can a higher stored version seen less
98759
+ than an hour ago win over the fresh tag. Older observations yield to npm. If the check fails, it
98760
+ uses the stored version and says so. Checks preserve install errors until the next install attempt.
98761
+ An install asked for with nothing newer known checks first; nothing newer is then "Up to date", not
98762
+ a failure. The installed app is replaced by the npm release, even a higher released version, except
98763
+ a newer local build (<base>.local.N) waiting to be applied, which is kept; the line says so.
98411
98764
  Once installed, the desktop app offers "Apply now": it relaunches into the new version, and running
98412
98765
  agents, terminals and commands keep going. "Apply updates automatically when idle" (off by default)
98413
98766
  installs a new version without a click and applies it once no session is working or waiting, no
98414
- terminal or command is running, and the person is not typing.
98415
- From the terminal: \`centralu update\`.`
98767
+ terminal or command is running, and the person is not typing. An agent can ask for the same Apply now
98768
+ (update_centralu, action apply): under auto it applies, under normal the person is asked on a card,
98769
+ under safe it is refused, and it never applies what the button would not offer. With it on, a failed install is tried
98770
+ again after 10 minutes, then 1 hour, then every 6 hours; the error stays until the next attempt.
98771
+ From the terminal: \`centralu update\`. \`centralu install --force\` replaces a kept local build.`
98416
98772
  };
98417
98773
  function gist(description) {
98418
98774
  const first = description.split(/ — |\. /)[0] ?? description;
@@ -98471,8 +98827,8 @@ function trustedJsonText(value) {
98471
98827
  var ORCHESTRATOR_TOOLS = [
98472
98828
  {
98473
98829
  name: "update_centralu",
98474
- description: "Checks for or installs a Centralu update. Installing never changes the person's automatic-update setting.",
98475
- schema: external_exports.object({ action: external_exports.enum(["check", "install"]) })
98830
+ description: "Checks for or installs a Centralu update, or applies the installed one (the window relaunches; agents keep running). Never changes the person's automatic-update setting.",
98831
+ schema: external_exports.object({ action: external_exports.enum(["check", "install", "apply"]) })
98476
98832
  },
98477
98833
  {
98478
98834
  name: "list_sessions",
@@ -98610,8 +98966,9 @@ var BUILDER_TOOL_NAMES = ["check"];
98610
98966
  var READER_TOOLS = [
98611
98967
  {
98612
98968
  name: "update_centralu",
98969
+ // "apply" (#665) needs no words of its own here: 8 characters in every reader's request, and the install answer names it
98613
98970
  description: "Updates Centralu with consent.",
98614
- schema: external_exports.object({ action: external_exports.enum(["check", "install"]) }),
98971
+ schema: external_exports.object({ action: external_exports.enum(["check", "install", "apply"]) }),
98615
98972
  // Updates are rare (#581); keep the definition behind Claude's tool search like app_guide.
98616
98973
  deferred: true
98617
98974
  },
@@ -98771,6 +99128,11 @@ function askProjectText(r) {
98771
99128
  }
98772
99129
  function agentRunText(r) {
98773
99130
  if (!r.ok) return { text: r.error, isError: true };
99131
+ if (r.state === "pending") {
99132
+ return {
99133
+ text: `No session yet: starting ${r.tool} is waiting for the person's answer on the card in your session (request ${r.sessionId}). Carry on with other work, and do not do this with your own subagents instead. If they allow it, the session starts and you will be told when its turn ends; if they refuse or it is withdrawn, you will be told. agent_session(sessionId="${r.sessionId}", action="status") shows where it stands, action="wait" waits for the answer, action="stop" withdraws the request.`
99134
+ };
99135
+ }
98774
99136
  const who = `the ${r.tool} session "${r.sessionName}" [${r.sessionId}]`;
98775
99137
  switch (r.state) {
98776
99138
  case "working":
@@ -98778,8 +99140,10 @@ function agentRunText(r) {
98778
99140
  text: `${who} is working on it${r.notice ? ` \u2014 ${r.notice}` : ""}. ` + (r.reported ? "You will be told when it ends; " : "") + `agent_session(sessionId="${r.sessionId}", action="wait") waits for its answer, action="stop" stops it.`
98779
99141
  };
98780
99142
  case "stopped":
99143
+ if (!r.sessionName) return { text: `Withdrew request ${r.sessionId}: its card is gone and no session was started.` };
98781
99144
  return { text: `Stopped ${who}.` };
98782
99145
  case "deleted":
99146
+ if (r.branch === void 0) return { text: `Moved ${who} to the trash. Tell the person in one line.` };
98783
99147
  return {
98784
99148
  text: (r.warning ? `Moved ${who} to the trash. Warning: ${r.warning} ` : `Cleaned up ${who}: its worktree and branch "${r.branch}" were removed and the session went to the trash. `) + (r.setAside.changedFiles || r.setAside.commits ? `${r.setAside.changedFiles} uncommitted files, ${r.setAside.commits} commits not on the trunk, kept in the trash. ` : "Nothing needed setting aside. ") + "Tell the person in one line."
98785
99149
  };
@@ -98802,13 +99166,25 @@ agent_session(sessionId="${r.sessionId}", action="send", text=\u2026) gives it m
98802
99166
  async function runOrchestratorTool(tools, name, args, caller = { sessionId: null, profile: "human" }, signal) {
98803
99167
  if (name === "update_centralu") {
98804
99168
  const action = args.action;
98805
- if (action !== "check" && action !== "install") return { text: "Give action: check or install.", isError: true };
98806
- const r = tools.updateCentralu ? await tools.updateCentralu(action) : { ok: false, error: "Update checks are unavailable" };
99169
+ if (action !== "check" && action !== "install" && action !== "apply") return { text: "Give action: check, install or apply.", isError: true };
99170
+ const r = tools.updateCentralu ? await tools.updateCentralu(action, signal) : { ok: false, error: "Update checks are unavailable" };
99171
+ if (action === "apply") {
99172
+ if (!r.ok) return { text: `Centralu update was not applied: ${r.error ?? "unavailable"}`, isError: true };
99173
+ const v = r.relaunched?.version;
99174
+ return {
99175
+ text: `Centralu is relaunching${v ? ` into ${v}` : " into the installed build"}. Running agents, this one included, keep going; the new window then moves the agent host to the new build by itself, and a Centralu tool call still running then may be stopped and need trying again.`
99176
+ };
99177
+ }
98807
99178
  if (!r.ok) return { text: `Centralu update did not start: ${r.error ?? "unavailable"}`, isError: true };
98808
99179
  const s = r.status;
98809
99180
  if (!s) return { text: "Centralu update is unavailable.", isError: true };
98810
99181
  if (action === "check") return { text: s.newer && s.latest ? `Centralu ${s.latest} is available.` : s.error ?? "Centralu is up to date." };
98811
- return { text: s.phase === "failed" ? `Centralu update failed: ${s.error}` : `Installing Centralu ${s.latest ?? ""}. The person applies the installed build from Centralu; their automatic-update setting was not changed.` };
99182
+ const applies = "The person applies the installed build from Centralu, or update_centralu with action apply does; their automatic-update setting was not changed.";
99183
+ if (s.phase === "failed") return { text: `Centralu update failed: ${s.error}` };
99184
+ if (s.phase === "restart_required") return { text: `Centralu ${s.latest ?? ""} is installed. ${applies}` };
99185
+ if (s.phase === "updating" || s.phase === "waiting_for_npm") return { text: `Installing Centralu ${s.latest ?? ""}. ${applies}` };
99186
+ if (s.newer && s.latest) return { text: `Centralu ${s.latest} is available, but the install did not start.`, isError: true };
99187
+ return { text: s.error ?? "Centralu is up to date." };
98812
99188
  }
98813
99189
  if (name === "list_sessions") return listSessionsText(tools, caller);
98814
99190
  if (name === "centralu_commands" || name === "centralu_help" || name === "centralu_run") return runCommandTool(tools, name, args, caller, signal);
@@ -100988,8 +101364,8 @@ function orchestratorMemory(reader, sessionId) {
100988
101364
 
100989
101365
  // packages/agent-host/src/sessions/manager.ts
100990
101366
  import { homedir as homedir12 } from "node:os";
100991
- import { basename as basename5, isAbsolute as isAbsolute9, join as join29, relative as relative8, resolve as resolve11, sep as sep11 } from "node:path";
100992
- import { existsSync as existsSync19, rmSync as rmSync7, statSync as statSync12 } from "node:fs";
101367
+ import { basename as basename5, dirname as dirname10, isAbsolute as isAbsolute9, join as join29, relative as relative8, resolve as resolve11, sep as sep11 } from "node:path";
101368
+ import { existsSync as existsSync19, realpathSync as realpathSync8, rmSync as rmSync7, statSync as statSync12 } from "node:fs";
100993
101369
  import { exec as exec6 } from "node:child_process";
100994
101370
 
100995
101371
  // packages/agent-host/src/adapters/claude/index.ts
@@ -101604,7 +101980,7 @@ async function readClaudeModels(q2) {
101604
101980
  }
101605
101981
 
101606
101982
  // packages/agent-host/src/adapters/local-process.ts
101607
- import { spawn as spawn4 } from "node:child_process";
101983
+ import { spawn as spawn5 } from "node:child_process";
101608
101984
  import { EventEmitter as EventEmitter3 } from "node:events";
101609
101985
  var EXIT_AFTER_OUTPUT_MS3 = 1e3;
101610
101986
  var STDERR_TAIL = 8 * 1024;
@@ -101634,7 +102010,7 @@ var LocalAgentProcess = class _LocalAgentProcess extends EventEmitter3 {
101634
102010
  exitReported = false;
101635
102011
  spawnedAt = Date.now();
101636
102012
  static spawn(spec, os2) {
101637
- const child = spawn4(spec.command, spec.args, {
102013
+ const child = spawn5(spec.command, spec.args, {
101638
102014
  cwd: spec.cwd,
101639
102015
  env: spec.env,
101640
102016
  stdio: ["pipe", "pipe", "pipe"],
@@ -102192,6 +102568,28 @@ var ClaudeBackgroundTracker = class {
102192
102568
  recover(messages2) {
102193
102569
  return this.schedules.recover(messages2) ? [this.event()] : [];
102194
102570
  }
102571
+ /**
102572
+ * The tasks the previous host last saw running in an adopted process (#648, `ProcessSource.adopt.backgroundTasks`).
102573
+ * The CLI sends its level only when it changes, so without this a tracker over an adopted process would list nothing
102574
+ * and, since only a task the level once listed counts as background work, report none of their endings either: the
102575
+ * session would read as idle and be put to sleep, or restarted, over a running shell. The manager lists them itself;
102576
+ * this only makes the tracker know them, so the next `task_notification` or level is read against them.
102577
+ */
102578
+ seed(tasks) {
102579
+ for (const t of tasks) {
102580
+ if (t.status !== "running" || t.stoppable === false || this.seen.has(t.id)) continue;
102581
+ this.info.set(t.id, {
102582
+ kind: t.kind,
102583
+ description: t.description,
102584
+ ...t.parentCallId ? { parentCallId: t.parentCallId } : {},
102585
+ ...t.ambient ? { ambient: true } : {},
102586
+ // `entry` derives stopsWithTurn from the kind unless the task is nested; a seeded one without it was nested
102587
+ ...t.stopsWithTurn === void 0 && (t.kind === "agent" || t.kind === "shell") ? { nested: true } : {}
102588
+ });
102589
+ this.live.push(t.id);
102590
+ this.seen.add(t.id);
102591
+ }
102592
+ }
102195
102593
  /** Drops the wake-ups whose time has passed; the new level when that changed anything */
102196
102594
  tick() {
102197
102595
  return this.schedules.prune() ? [this.event()] : [];
@@ -102219,7 +102617,7 @@ var ClaudeBackgroundTracker = class {
102219
102617
  this.live.push(id2);
102220
102618
  this.seen.add(id2);
102221
102619
  }
102222
- return [this.event()];
102620
+ return [this.event(void 0, true)];
102223
102621
  }
102224
102622
  if (subtype === "task_started" || subtype === "task_updated") {
102225
102623
  const id2 = str4(m.task_id);
@@ -102285,9 +102683,10 @@ var ClaudeBackgroundTracker = class {
102285
102683
  status: "running"
102286
102684
  };
102287
102685
  }
102288
- event(ended) {
102686
+ event(ended, snapshot2) {
102289
102687
  return {
102290
102688
  type: "background_tasks",
102689
+ ...snapshot2 ? { snapshot: snapshot2 } : {},
102291
102690
  sessionId: this.sessionId,
102292
102691
  live: [...this.live.map((id2) => this.entry(id2, this.info.get(id2))), ...this.schedules.live()],
102293
102692
  ...ended ? { ended } : {}
@@ -102818,10 +103217,19 @@ function readTarget(toolName, input) {
102818
103217
  const p = toolName === "Read" ? input.file_path : input.path;
102819
103218
  return typeof p === "string" && p ? p : null;
102820
103219
  }
102821
- function permissionOptionsFor(preset) {
102822
- if (preset === "auto") return { permissionMode: "bypassPermissions" };
102823
- if (preset === "safe") return { permissionMode: "default" };
102824
- return { resolvePermissionModeInCli: true };
103220
+ async function permissionOptionsFor(opts) {
103221
+ if (opts.permissionPreset === "auto") return { permissionMode: "bypassPermissions" };
103222
+ if (opts.permissionPreset === "safe") return { permissionMode: "default" };
103223
+ if (opts.noSettingFiles) return { permissionMode: await personsDefaultMode() ?? "default" };
103224
+ try {
103225
+ const resolved = await D9t({ cwd: opts.cwd, ...settingSourcesFor(opts) });
103226
+ const mode = qtt(resolved).permissions?.defaultMode;
103227
+ if (typeof mode === "string" && PERMISSION_MODES.includes(mode)) {
103228
+ return { permissionMode: mode };
103229
+ }
103230
+ } catch {
103231
+ }
103232
+ return { permissionMode: "default" };
102825
103233
  }
102826
103234
  var PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"];
102827
103235
  async function personsDefaultMode() {
@@ -102865,6 +103273,7 @@ var ClaudeSession = class {
102865
103273
  this.stream.goal.seed(opts.knownGoal);
102866
103274
  if (opts.processSource?.adopt) this.externalId = opts.resumeExternalId ?? null;
102867
103275
  if (opts.processSource?.adopt && opts.resumeExternalId) void this.recoverSchedules(opts.resumeExternalId, opts.cwd);
103276
+ if (opts.processSource?.adopt?.backgroundTasks?.length) this.stream.tasks.seed(opts.processSource.adopt.backgroundTasks);
102868
103277
  }
102869
103278
  sessionId;
102870
103279
  opts;
@@ -102961,7 +103370,7 @@ var ClaudeSession = class {
102961
103370
  this.orchestratorServer = orchestratorMcp(this.opts.orchestratorTools, this.opts.toolProfile, this.opts.sessionId);
102962
103371
  }
102963
103372
  const servers = this.mcpServers();
102964
- const personsMode = preset === "normal" && this.opts.noSettingFiles ? await personsDefaultMode() : null;
103373
+ const permissionOptions = await permissionOptionsFor(this.opts);
102965
103374
  if (!this.opts.processSource?.adopt) await this.launch.gate.turn();
102966
103375
  this.runKey = run?.key ?? null;
102967
103376
  this.launch.links.acquire(this.runKey);
@@ -103054,7 +103463,7 @@ var ClaudeSession = class {
103054
103463
  */
103055
103464
  ...this.opts.systemPromptAppend ? { systemPrompt: { type: "preset", preset: "claude_code", append: this.opts.systemPromptAppend } } : {}
103056
103465
  } : {},
103057
- ...personsMode ? { permissionMode: personsMode } : permissionOptionsFor(preset),
103466
+ ...permissionOptions,
103058
103467
  /*
103059
103468
  * The agent an app hands a schema and asks for output (M4 D-1). It is a per-query option,
103060
103469
  * so it can only be given when the session starts. Measured (SDK 0.3.263, CLI 2.1.282,
@@ -103296,8 +103705,8 @@ var ClaudeSession = class {
103296
103705
  * turn released, rather than left hanging; the person can send the message again.
103297
103706
  */
103298
103707
  releaseLostCalls(q2) {
103299
- const open5 = this.opts.processSource?.adopt?.openCalls ?? [];
103300
- const lost = open5.filter((c) => isOrchestratorTool(c.tool) || appToolOf(c.tool) !== null);
103708
+ const open6 = this.opts.processSource?.adopt?.openCalls ?? [];
103709
+ const lost = open6.filter((c) => isOrchestratorTool(c.tool) || appToolOf(c.tool) !== null);
103301
103710
  if (lost.length === 0) return;
103302
103711
  this.turnOpen = true;
103303
103712
  this.stream.stopped();
@@ -103428,11 +103837,11 @@ var ClaudeSession = class {
103428
103837
  * the model receives the answers in the CLI's own words (see the AskUserQuestion branch of `canUseTool`).
103429
103838
  */
103430
103839
  answerQuestion(requestId, answers) {
103431
- const open5 = this.questions.get(requestId);
103432
- if (!open5) return false;
103840
+ const open6 = this.questions.get(requestId);
103841
+ if (!open6) return false;
103433
103842
  this.questions.delete(requestId);
103434
103843
  const byQuestion = Object.fromEntries(answers.map((a) => [a.question, a.answers.join(", ")]));
103435
- open5.resolve({ behavior: "allow", updatedInput: { ...open5.input, answers: byQuestion } });
103844
+ open6.resolve({ behavior: "allow", updatedInput: { ...open6.input, answers: byQuestion } });
103436
103845
  this.emit({ type: "question_resolved", sessionId: this.sessionId, requestId });
103437
103846
  return true;
103438
103847
  }
@@ -103708,8 +104117,8 @@ var ClaudeSession = class {
103708
104117
  }
103709
104118
  /** Releases a choice that was still waiting on an answer — **never released silently**, for the same reason as approval. */
103710
104119
  releaseQuestions(why) {
103711
- for (const [id2, open5] of this.questions) {
103712
- open5.resolve({ behavior: "deny", message: why });
104120
+ for (const [id2, open6] of this.questions) {
104121
+ open6.resolve({ behavior: "deny", message: why });
103713
104122
  this.emit({ type: "question_resolved", sessionId: this.sessionId, requestId: id2 });
103714
104123
  }
103715
104124
  this.questions.clear();
@@ -103815,7 +104224,7 @@ var ClaudeAdapter = class _ClaudeAdapter {
103815
104224
  sleep: true
103816
104225
  };
103817
104226
  launchSettingsFingerprint(settings) {
103818
- return JSON.stringify({ v: settings.permissionPreset === "auto" ? 2 : 1, cwd: settings.cwd, permission: settings.permissionPreset, trusted: !!settings.projectTrusted, noSettings: !!settings.noSettingFiles });
104227
+ return JSON.stringify({ v: settings.permissionPreset === "safe" ? 1 : 2, cwd: settings.cwd, permission: settings.permissionPreset, trusted: !!settings.projectTrusted, noSettings: !!settings.noSettingFiles });
103819
104228
  }
103820
104229
  async detect() {
103821
104230
  const path2 = whichTool("claude");
@@ -104163,7 +104572,7 @@ var CodexClient = class {
104163
104572
  };
104164
104573
 
104165
104574
  // packages/agent-host/src/adapters/codex/rollout.ts
104166
- import { createReadStream as createReadStream2 } from "node:fs";
104575
+ import { createReadStream as createReadStream3 } from "node:fs";
104167
104576
  import { readdir as readdir5 } from "node:fs/promises";
104168
104577
  import { createInterface as createInterface2 } from "node:readline";
104169
104578
  import { homedir as homedir6 } from "node:os";
@@ -104199,7 +104608,7 @@ async function lastCompactSummary(threadId, sessionsDir) {
104199
104608
  if (!path2) return null;
104200
104609
  try {
104201
104610
  let last = null;
104202
- const rl2 = createInterface2({ input: createReadStream2(path2, "utf8"), crlfDelay: Infinity });
104611
+ const rl2 = createInterface2({ input: createReadStream3(path2, "utf8"), crlfDelay: Infinity });
104203
104612
  for await (const line of rl2) {
104204
104613
  if (!line.includes('"compacted"')) continue;
104205
104614
  try {
@@ -106321,10 +106730,10 @@ var CodexSession = class {
106321
106730
  return this.opts.outputSchema ? { outputSchema: this.opts.outputSchema } : {};
106322
106731
  }
106323
106732
  respondApproval(requestId, decision, _scope, matcher) {
106324
- const open5 = this.approvals.get(requestId);
106325
- if (open5 === void 0) return false;
106733
+ const open6 = this.approvals.get(requestId);
106734
+ if (open6 === void 0) return false;
106326
106735
  this.approvals.delete(requestId);
106327
- const serverId = open5.serverId;
106736
+ const serverId = open6.serverId;
106328
106737
  if (this.elicitations.delete(requestId)) {
106329
106738
  this.client.respond(serverId, {
106330
106739
  action: decision === "deny" ? "decline" : "accept",
@@ -106737,6 +107146,33 @@ function registeredTool(adapters2, preferred) {
106737
107146
  return adapters2.keys().next().value ?? null;
106738
107147
  }
106739
107148
 
107149
+ // packages/agent-host/src/sessions/tool-profiles.ts
107150
+ function isAppAgent(f) {
107151
+ return !!f.appId && f.kind === "worker" && !RESERVED_APP_IDS.includes(f.appId) && !f.builder;
107152
+ }
107153
+ function readsOwnProject(f) {
107154
+ return f.kind === "worker" && !!f.projectId && !isAppAgent(f) && f.sessionTools;
107155
+ }
107156
+ function toolProfileFor(f) {
107157
+ if (f.kind === "orchestrator") return "orchestrator";
107158
+ if (f.kind === "coordinator") return "scoped";
107159
+ if (f.builder) return "builder";
107160
+ return readsOwnProject(f) ? "reader" : null;
107161
+ }
107162
+ function toolBundleFor(profile, scopeSessionIds, sources) {
107163
+ switch (profile) {
107164
+ case "orchestrator":
107165
+ case "builder":
107166
+ return sources.orchestrator();
107167
+ case "scoped":
107168
+ return sources.scoped(scopeSessionIds ?? []);
107169
+ case "reader":
107170
+ return sources.reader();
107171
+ default:
107172
+ return void 0;
107173
+ }
107174
+ }
107175
+
106740
107176
  // packages/agent-host/src/dev-services/store.ts
106741
107177
  import Database from "better-sqlite3";
106742
107178
  import { existsSync as existsSync18, readFileSync as readFileSync12 } from "node:fs";
@@ -108055,6 +108491,70 @@ var Store = class _Store {
108055
108491
  WHERE asked_by_session_id IS NULL AND parent_session_id IS NOT NULL AND worktree_path IS NOT NULL
108056
108492
  `);
108057
108493
  }
108494
+ },
108495
+ {
108496
+ to: 49,
108497
+ breaksOlderReaders: false,
108498
+ /**
108499
+ * "You will be told when it ends" (#646) — `report_marks`, one row per session that owes a report to the session
108500
+ * that gave it work (`start_agent` with `wait: false` or past the bound, `send_to_session` with `reportBack`).
108501
+ *
108502
+ * session_id the session whose turn end is reported
108503
+ * caller_session_id the session told about it
108504
+ * marked_at when the report was asked for
108505
+ *
108506
+ * The mark used to live in the host's memory alone (`SessionManager.awaitingReport`), so a host swap (#280) lost
108507
+ * it: the keeper kept the child's process, it finished under the new host, and the parent was never woken. The
108508
+ * new host reads the rows at startup and keeps the marks of the sessions the keeper kept; a mark whose session
108509
+ * has no process any more is dropped, since the turn it was about died with the old host.
108510
+ *
108511
+ * Not foreign keys: a caller in the trash still has its mark cleared by the manager, and purging either session
108512
+ * deletes the row (`purgeSession`).
108513
+ *
108514
+ * **Expand only (#292's rule).** A new table; an older host never reads it.
108515
+ */
108516
+ run: () => {
108517
+ this.db.exec(`
108518
+ CREATE TABLE IF NOT EXISTS report_marks (
108519
+ session_id TEXT PRIMARY KEY,
108520
+ caller_session_id TEXT NOT NULL,
108521
+ marked_at INTEGER NOT NULL
108522
+ )
108523
+ `);
108524
+ }
108525
+ },
108526
+ {
108527
+ to: 50,
108528
+ breaksOlderReaders: false,
108529
+ /**
108530
+ * start_agent requests still waiting for the person (#673) — `agent_start_requests`, one row per consent card
108531
+ * a session raised to start another agent, from the moment the card stands until the person answers it or it is
108532
+ * withdrawn.
108533
+ *
108534
+ * request_id the card's request id, which is also the handle the caller follows with agent_session
108535
+ * caller_session_id the session that asked
108536
+ * request what the start needs once allowed, as JSON (`AgentStartRequest` in the manager)
108537
+ * asked_at when the card was raised
108538
+ *
108539
+ * The card used to be withdrawn after 200 seconds, so an absent person never saw it. It now stands until it is
108540
+ * answered, which can outlive the host: a host swap (#280) or a restart reads these rows and raises the cards
108541
+ * again, and an approval starts the child then. A row whose caller is no longer there is dropped.
108542
+ *
108543
+ * Not foreign keys, as `report_marks`: trashing the caller withdraws its rows through the manager, and purging
108544
+ * deletes them (`purgeSession`).
108545
+ *
108546
+ * **Expand only (#292's rule).** A new table; an older host never reads it.
108547
+ */
108548
+ run: () => {
108549
+ this.db.exec(`
108550
+ CREATE TABLE IF NOT EXISTS agent_start_requests (
108551
+ request_id TEXT PRIMARY KEY,
108552
+ caller_session_id TEXT NOT NULL,
108553
+ request TEXT NOT NULL,
108554
+ asked_at INTEGER NOT NULL
108555
+ )
108556
+ `);
108557
+ }
108058
108558
  }
108059
108559
  ];
108060
108560
  }
@@ -108248,11 +108748,16 @@ var Store = class _Store {
108248
108748
  this.checkpoint();
108249
108749
  this.db.close();
108250
108750
  }
108751
+ /**
108752
+ * `trusted` is the trust a NEW row starts with (#655); absent means untrusted, as before. It is an insert-only
108753
+ * value: registering a path that already has a row renames it and leaves its trust alone, so neither an earlier
108754
+ * "no" nor a later "yes" is overwritten by a second registration.
108755
+ */
108251
108756
  addProject(p) {
108252
108757
  this.db.prepare(
108253
- `INSERT INTO projects (id, path, name, created_at) VALUES (?, ?, ?, ?)
108758
+ `INSERT INTO projects (id, path, name, created_at, trusted) VALUES (?, ?, ?, ?, ?)
108254
108759
  ON CONFLICT(path) DO UPDATE SET name = excluded.name`
108255
- ).run(p.id, p.path, p.name, Date.now());
108760
+ ).run(p.id, p.path, p.name, Date.now(), p.trusted ? 1 : 0);
108256
108761
  }
108257
108762
  /**
108258
108763
  * `commands` is deliberately not in here (issue #44) — `projectCommands` answers that one.
@@ -108824,6 +109329,8 @@ var Store = class _Store {
108824
109329
  this.db.prepare(`DELETE FROM commit_sessions WHERE session_id = ?`).run(sessionId);
108825
109330
  this.db.prepare(`DELETE FROM app_run_failures WHERE run_id IN (SELECT id FROM app_runs WHERE session_id = ? OR caller_session_id = ?)`).run(sessionId, sessionId);
108826
109331
  this.db.prepare(`DELETE FROM app_runs WHERE session_id = ? OR caller_session_id = ?`).run(sessionId, sessionId);
109332
+ this.db.prepare(`DELETE FROM report_marks WHERE session_id = ? OR caller_session_id = ?`).run(sessionId, sessionId);
109333
+ this.db.prepare(`DELETE FROM agent_start_requests WHERE caller_session_id = ?`).run(sessionId);
108827
109334
  this.db.prepare(`DELETE FROM sessions WHERE id = ? AND deleted_at IS NOT NULL`).run(sessionId);
108828
109335
  return true;
108829
109336
  });
@@ -109411,6 +109918,39 @@ var Store = class _Store {
109411
109918
  ).all();
109412
109919
  return rows.filter((r) => ProjectConsentKind.safeParse(r.kind).success);
109413
109920
  }
109921
+ // ── Sessions that owe a report to the session that gave them work (#646) — see migration v49 ──
109922
+ listReportMarks() {
109923
+ return this.db.prepare(`SELECT session_id as sessionId, caller_session_id as callerSessionId FROM report_marks ORDER BY marked_at`).all();
109924
+ }
109925
+ setReportMark(sessionId, callerSessionId) {
109926
+ this.db.prepare(
109927
+ `INSERT INTO report_marks (session_id, caller_session_id, marked_at) VALUES (?, ?, ?)
109928
+ ON CONFLICT(session_id) DO UPDATE SET caller_session_id = excluded.caller_session_id, marked_at = excluded.marked_at`
109929
+ ).run(sessionId, callerSessionId, Date.now());
109930
+ }
109931
+ clearReportMark(sessionId) {
109932
+ this.db.prepare(`DELETE FROM report_marks WHERE session_id = ?`).run(sessionId);
109933
+ }
109934
+ // ── start_agent requests waiting for the person (#673) — see migration v50 ──
109935
+ listAgentStartRequests() {
109936
+ const rows = this.db.prepare(`SELECT request_id as requestId, caller_session_id as callerSessionId, request, asked_at as askedAt FROM agent_start_requests ORDER BY asked_at`).all();
109937
+ return rows.flatMap((r) => {
109938
+ try {
109939
+ return [{ ...r, request: JSON.parse(r.request) }];
109940
+ } catch {
109941
+ return [];
109942
+ }
109943
+ });
109944
+ }
109945
+ putAgentStartRequest(requestId, callerSessionId, request) {
109946
+ this.db.prepare(
109947
+ `INSERT INTO agent_start_requests (request_id, caller_session_id, request, asked_at) VALUES (?, ?, ?, ?)
109948
+ ON CONFLICT(request_id) DO UPDATE SET caller_session_id = excluded.caller_session_id, request = excluded.request`
109949
+ ).run(requestId, callerSessionId, JSON.stringify(request), Date.now());
109950
+ }
109951
+ deleteAgentStartRequest(requestId) {
109952
+ this.db.prepare(`DELETE FROM agent_start_requests WHERE request_id = ?`).run(requestId);
109953
+ }
109414
109954
  // ── Linked machines (#82, v46) ─────────────────────────────────────────────────────────────
109415
109955
  listLinkedMachines() {
109416
109956
  const rows = this.db.prepare(
@@ -109580,9 +110120,9 @@ async function gitStatusFiles(cwd, trust = {}) {
109580
110120
  if (entry.startsWith("1 ") || entry.startsWith("2 ")) {
109581
110121
  const parts = entry.split(" ");
109582
110122
  const xy = parts[1] ?? "..";
109583
- const rename5 = entry.startsWith("2 ");
109584
- const path2 = parts.slice(rename5 ? 9 : 8).join(" ");
109585
- if (rename5) i++;
110123
+ const rename6 = entry.startsWith("2 ");
110124
+ const path2 = parts.slice(rename6 ? 9 : 8).join(" ");
110125
+ if (rename6) i++;
109586
110126
  if (!path2) continue;
109587
110127
  const [x, y] = [xy[0] ?? ".", xy[1] ?? "."];
109588
110128
  if (x !== ".") out.push({ path: path2, staged: true, status: mapStatus(x) });
@@ -110100,7 +110640,7 @@ async function sweepAttachments(maxBytes = ATTACHMENTS_MAX_BYTES) {
110100
110640
 
110101
110641
  // packages/agent-host/src/dev-services/message-image.ts
110102
110642
  import { constants as constants7 } from "node:fs";
110103
- import { open as open4, realpath as realpath7 } from "node:fs/promises";
110643
+ import { open as open5, realpath as realpath7 } from "node:fs/promises";
110104
110644
  import { homedir as homedir11 } from "node:os";
110105
110645
  import { posix as posix3, sep as sep10, win32 as win326 } from "node:path";
110106
110646
  import { fileURLToPath as fileURLToPath5 } from "node:url";
@@ -110165,7 +110705,7 @@ async function readMessageImage(abs, max = IMAGE_PREVIEW_MAX_BYTES) {
110165
110705
  }
110166
110706
  let handle;
110167
110707
  try {
110168
- handle = await open4(file2, OPEN_FLAGS);
110708
+ handle = await open5(file2, OPEN_FLAGS);
110169
110709
  } catch (e) {
110170
110710
  const code2 = e.code;
110171
110711
  if (code2 === "EACCES" || code2 === "EPERM") return refuse5("unreadable", `Centralu may not read ${file2}`, file2);
@@ -110266,12 +110806,72 @@ function attachCommitSessions(commits, records, nameOf) {
110266
110806
 
110267
110807
  // packages/agent-host/src/sessions/manager.ts
110268
110808
  function withTimeout(p, ms3, label = "A call") {
110809
+ let timer;
110269
110810
  return Promise.race([
110270
110811
  p,
110271
- new Promise(
110272
- (_, reject) => setTimeout(() => reject(new Error(`${label} did not finish within ${Math.round(ms3 / 1e3)}s`)), ms3)
110273
- )
110274
- ]);
110812
+ new Promise((_, reject) => {
110813
+ timer = setTimeout(() => reject(new Error(`${label} did not finish within ${Math.round(ms3 / 1e3)}s`)), ms3);
110814
+ })
110815
+ ]).finally(() => clearTimeout(timer));
110816
+ }
110817
+ var MODEL_LIST_READ_MS = 6e4;
110818
+ var MODEL_LIST_RETRY_MS = 6e4;
110819
+ var START_AGENT_MODEL_CHECK_MS = 3e3;
110820
+ var START_AGENT_SETUP_MS = 2e5;
110821
+ var START_AGENT_CALL_MS = 27e4;
110822
+ var ASK_THE_PERSON = "Do not do this with your own subagents instead: tell the person what stopped it and let them decide.";
110823
+ var AGENT_DEPTH_ERROR = "This session was itself started or asked by another session, so it cannot start agents \u2014 say in your answer what is needed instead.";
110824
+ function readAgentStartRequest(raw) {
110825
+ const r = raw;
110826
+ if (!r || typeof r !== "object") return null;
110827
+ if (typeof r.requestId !== "string" || typeof r.callerId !== "string" || typeof r.projectId !== "string") return null;
110828
+ if (typeof r.tool !== "string" || typeof r.task !== "string" || r.detail?.kind !== "agent_start") return null;
110829
+ return r;
110830
+ }
110831
+ function agentStartPending(req) {
110832
+ return { ok: true, state: "pending", sessionId: req.requestId, sessionName: "", tool: req.detail.tool };
110833
+ }
110834
+ function agentStartPersonText(req, how, why) {
110835
+ const what = `The request to start ${req.detail.tool} (\u201C${req.detail.text}\u201D)`;
110836
+ switch (how) {
110837
+ case "denied":
110838
+ return `${what} was denied. The session was told; no agent was started.`;
110839
+ case "cancelled":
110840
+ return `${what} was withdrawn: the call that asked was cancelled before you answered. No agent was started.`;
110841
+ case "deleted":
110842
+ return `${what} was withdrawn: this session was deleted. No agent was started.`;
110843
+ case "stopped":
110844
+ return `${what} was withdrawn by this session. No agent was started.`;
110845
+ case "untrusted":
110846
+ return `${what} was withdrawn: the project is no longer trusted. The session was told; no agent was started.`;
110847
+ case "failed":
110848
+ return `${what} was allowed, but no agent was started: ${(why ?? "").slice(0, 300)}`;
110849
+ default:
110850
+ return `${what} was withdrawn before you answered. No agent was started.`;
110851
+ }
110852
+ }
110853
+ function startAgentLate(progress) {
110854
+ const seconds = Math.round(START_AGENT_SETUP_MS / 1e3);
110855
+ const note = progress.unchecked ? ` (${progress.unchecked}.)` : "";
110856
+ switch (progress.stage) {
110857
+ case "created":
110858
+ return {
110859
+ ok: false,
110860
+ error: `The session [${progress.childId}] was created, but handing it the task took longer than ${seconds} seconds. Do not start another: agent_session(sessionId="${progress.childId}", action="status") shows where it stands. ${ASK_THE_PERSON}${note}`
110861
+ };
110862
+ case "creating":
110863
+ return {
110864
+ ok: false,
110865
+ error: `Starting the session took longer than ${seconds} seconds and had not finished, so a session may or may not exist. Do not call start_agent again until read_session (without a sessionId) shows whether a new session is there; if one starts, you will be told when it ends. ${ASK_THE_PERSON}${note}`
110866
+ };
110867
+ case "asking":
110868
+ return {
110869
+ ok: false,
110870
+ error: `No session was created: the person did not answer the question in this session within ${seconds} seconds, so it was withdrawn. ${ASK_THE_PERSON}`
110871
+ };
110872
+ default:
110873
+ return { ok: false, error: `No session was created: checking the request took longer than ${seconds} seconds. Try again once; if it repeats, ask the person. ${ASK_THE_PERSON}` };
110874
+ }
110275
110875
  }
110276
110876
  var HISTORY_LIMIT = 200;
110277
110877
  var SYNC_LIMIT = 600;
@@ -110308,10 +110908,26 @@ function isCompactionMarker(row) {
110308
110908
  return row.kind === "marker" && p?.type === "compaction" && !p.failed;
110309
110909
  }
110310
110910
  var inTurn = (state) => state === "working" || state === "waiting_approval";
110911
+ var BACKGROUND_UNKNOWN_MAX_MS = 2 * 60 * 60 * 1e3;
110912
+ var BACKGROUND_HOLD_MAX_MS = 60 * 60 * 1e3;
110913
+ function canonicalPath(path2) {
110914
+ const full = resolve11(path2);
110915
+ try {
110916
+ return realpathSync8.native(full);
110917
+ } catch {
110918
+ const parent = dirname10(full);
110919
+ return parent === full ? full : join29(canonicalPath(parent), basename5(full));
110920
+ }
110921
+ }
110922
+ function insideFolder(path2, folder) {
110923
+ const rel = relative8(folder, path2);
110924
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep11}`) && !isAbsolute9(rel);
110925
+ }
110311
110926
  var EXTERNAL_LIST_LIMIT = 200;
110312
110927
  var HOST_READ_MAX_LINES = 200;
110313
110928
  var HOST_RECALL_MAX_HITS = 50;
110314
110929
  var HUB_SENDER = "_hub-orchestrator";
110930
+ var HOST_SENDER = "_centralu";
110315
110931
  var builderKey = (ref) => `${ref.projectId ?? "_user"}/${ref.appId}`;
110316
110932
  var APP_BUILDERS_KEY = "apps.builders";
110317
110933
  var SessionManager = class {
@@ -110344,8 +110960,29 @@ var SessionManager = class {
110344
110960
  store2.upsertSession(fixed);
110345
110961
  }
110346
110962
  }
110963
+ for (const mark of store2.listReportMarks()) {
110964
+ const child = this.meta.get(mark.sessionId);
110965
+ const running = keeperOpts.keptSessions?.has(mark.sessionId) && !!child && inTurn(child.state) && this.meta.has(mark.callerSessionId);
110966
+ if (running) {
110967
+ this.awaitingReport.set(mark.sessionId, mark.callerSessionId);
110968
+ continue;
110969
+ }
110970
+ if (child && keeperOpts.keptSessions?.has(mark.sessionId)) {
110971
+ console.error(`[agent-host] report mark dropped: ${mark.sessionId.slice(0, 8)} ended its turn during the swap`);
110972
+ }
110973
+ store2.clearReportMark(mark.sessionId);
110974
+ }
110975
+ for (const row of store2.listAgentStartRequests()) {
110976
+ const req = readAgentStartRequest(row.request);
110977
+ if (!req || req.requestId !== row.requestId || req.callerId !== row.callerSessionId) {
110978
+ store2.deleteAgentStartRequest(row.requestId);
110979
+ continue;
110980
+ }
110981
+ this.resumeAgentStart(req);
110982
+ }
110347
110983
  void this.sweepOrphanHandoffNotes().catch(() => {
110348
110984
  });
110985
+ this.recoveryRepair = repairWorktreeRecoveryRemovals((id2) => store2.isTrashed(id2)).catch((e) => console.error(`[trash] recovery folders left by an interrupted purge were not repaired: ${e.message}`));
110349
110986
  this.nameUnnamedWorktrees();
110350
110987
  for (const pid of new Set(
110351
110988
  [...this.meta.values()].filter((s) => s.worktree && s.projectId).map((s) => s.projectId)
@@ -110416,8 +111053,30 @@ var SessionManager = class {
110416
111053
  cacheLedger = new CacheLedger();
110417
111054
  /** Why a session's process is being restarted, for the cache's rebuild cause; taken when the new process comes up */
110418
111055
  restartReasons = /* @__PURE__ */ new Map();
110419
- /** Session asked to be reported on completion -> orchestrator to notify (removed once notified) */
111056
+ /**
111057
+ * Session asked to be reported on completion -> orchestrator to notify (removed once notified). Mirrored in the
111058
+ * store's `report_marks` (v49, #646) through `markReport` / `clearReport`, so a host swap (#280) does not lose
111059
+ * "you will be told when it ends": the constructor reads the marks back for the sessions the keeper kept.
111060
+ */
110420
111061
  awaitingReport = /* @__PURE__ */ new Map();
111062
+ /**
111063
+ * `detachAll` is handing the sessions to the next host (#280): the waits it fails are not the end of their turns,
111064
+ * so no report goes out and the marks stay in the store for the host that sees those turns end (#646).
111065
+ */
111066
+ handingOver = false;
111067
+ /**
111068
+ * An auto observer's own mid-turn note (#646), `<observer>><session>` -> the reader its receipt is held on. The
111069
+ * observer's `status` and `wait` read this, never the owner's delegation, until a read takes its settled outcome.
111070
+ */
111071
+ observerNotes = /* @__PURE__ */ new Map();
111072
+ markReport(sessionId, callerId) {
111073
+ this.awaitingReport.set(sessionId, callerId);
111074
+ this.store.setReportMark(sessionId, callerId);
111075
+ }
111076
+ clearReport(sessionId) {
111077
+ if (!this.awaitingReport.delete(sessionId)) return;
111078
+ this.store.clearReportMark(sessionId);
111079
+ }
110421
111080
  /**
110422
111081
  * Session currently being resumed -> its promise.
110423
111082
  *
@@ -110447,9 +111106,31 @@ var SessionManager = class {
110447
111106
  definitionsRestartAfterTurn = /* @__PURE__ */ new Set();
110448
111107
  /** A kept process was started with adapter settings from an older host build (#589). */
110449
111108
  launchSettingsRestartAfterTurn = /* @__PURE__ */ new Set();
111109
+ /** Sessions whose pending launch-settings restart comes from a change of their project's trust (#663): the restart says so */
111110
+ trustRestartNotice = /* @__PURE__ */ new Set();
111111
+ /**
111112
+ * Sessions whose adopted process may hold background work this host knows nothing of (#648) -> when the session was
111113
+ * last heard from. The previous host left no list of it (a build before the list was kept), and the tool says its
111114
+ * level only when it changes. Until the tool's own full list arrives (`snapshot`), the session is never idle
111115
+ * (`sessionIdle`: `background_unknown`), so it is not put to sleep over a shell nobody can see; after
111116
+ * `BACKGROUND_UNKNOWN_MAX_MS` without a word it is taken as holding nothing (`backgroundKnown`). A safety restart does
111117
+ * not wait for it (`applyDeferredRestart`). A fresh process starts known: it holds nothing yet.
111118
+ */
111119
+ backgroundUnknown = /* @__PURE__ */ new Map();
111120
+ /**
111121
+ * A deferred restart that changes nothing about safety, waiting for the session's background work to end (#648) ->
111122
+ * when the wait began, and the timer that applies it anyway `BACKGROUND_HOLD_MAX_MS` later. The entry outlives its
111123
+ * timer until the restart is applied: a timer that fires mid-turn finds the turn running, and the turn's end applies
111124
+ * the restart then, counting from `since` rather than starting a new hour.
111125
+ */
111126
+ backgroundHolds = /* @__PURE__ */ new Map();
110450
111127
  updates = null;
110451
111128
  /** Sessions allowed to update once per host; an "always" answer adds this session only. */
110452
111129
  updateConsents = /* @__PURE__ */ new Set();
111130
+ /** Asking a window to apply the installed update (#665); null on a host with no windows to ask (tests) */
111131
+ updateApply = null;
111132
+ /** Sessions whose "always" covers applying (#665), kept apart from check and install: a relaunch is the person's window */
111133
+ updateApplyConsents = /* @__PURE__ */ new Set();
110453
111134
  /**
110454
111135
  * Where external apps are attached to a session (M4 A-5). It is null on a host with no runtime
110455
111136
  * (most tests), and in that case the session accepts no apps — it is optional, like any other
@@ -110499,6 +111180,21 @@ var SessionManager = class {
110499
111180
  * `readGrants`: a restart asks again. The project-wide always is a stored consent instead.
110500
111181
  */
110501
111182
  agentConsents = /* @__PURE__ */ new Set();
111183
+ /**
111184
+ * start_agent requests whose card stands or stood (#673) — request id -> the request. A card is no longer withdrawn
111185
+ * when the call's bound passes: the call answers "pending" with the request id as a handle, the card stays until the
111186
+ * person answers, and an approval starts the child then. Mirrored in the store's `agent_start_requests` while it
111187
+ * stands, so the next host raises it again (`resumeAgentStart`). Kept after it ends, for agent_session on the handle,
111188
+ * until the caller goes.
111189
+ */
111190
+ agentStarts = /* @__PURE__ */ new Map();
111191
+ /**
111192
+ * Places taken by starts between their ceiling check and the child's delegation (#673 review) — project id -> how
111193
+ * many. Counted by `agentCeilingError` with the running children, so starts at once cannot pass the ceiling together.
111194
+ */
111195
+ agentStartReserved = /* @__PURE__ */ new Map();
111196
+ /** `disposeAll` has begun (#673): a card it withdraws is not the person's answer, and its request stays stored */
111197
+ disposing = false;
110502
111198
  /**
110503
111199
  * What a session may read beyond its folder because another project handed it back (#371 part B) — session id ->
110504
111200
  * resolved paths (files, or output folders). Live only: the grant lasts while this host serves the session, and a
@@ -110551,13 +111247,18 @@ var SessionManager = class {
110551
111247
  { code: "internal" }
110552
111248
  );
110553
111249
  }
110554
- async addProject(path2) {
111250
+ /**
111251
+ * `trusted` is the trust a project new to this host starts with (#655); registering a folder that is already a
111252
+ * project never changes its trust. Registering an existing folder from the screen leaves it untrusted, as the
111253
+ * person decides; only `createProject` passes it, for a folder it made empty itself.
111254
+ */
111255
+ async addProject(path2, opts = {}) {
110555
111256
  if (!existsSync19(path2) || !statSync12(path2).isDirectory()) {
110556
111257
  throw Object.assign(new Error(`Directory not found: ${path2}`), { code: "internal" });
110557
111258
  }
110558
111259
  const existing = this.store.findProjectByPath(path2);
110559
111260
  const id2 = existing?.id ?? randomUUID10();
110560
- this.store.addProject({ id: id2, path: path2, name: basename5(path2) });
111261
+ this.store.addProject({ id: id2, path: path2, name: basename5(path2), ...opts.trusted ? { trusted: true } : {} });
110561
111262
  return this.projectInfo(id2, path2);
110562
111263
  }
110563
111264
  /**
@@ -110602,7 +111303,7 @@ var SessionManager = class {
110602
111303
  /**
110603
111304
  * Makes a new project (#515): a folder named `name` in `parent` (this machine's `~/Centralu` when absent), empty, or a
110604
111305
  * clone of `source` made with this machine's git and saved sign-ins, then registers it. Never reuses anything
110605
- * already there (`projectFolderTarget`). A clone that fails leaves nothing behind: the folder this made is removed
111306
+ * already there (`projectFolderTarget`). An empty folder is registered trusted, a clone untrusted (#655). A clone that fails leaves nothing behind: the folder this made is removed
110606
111307
  * and no project is registered.
110607
111308
  */
110608
111309
  async createProject(name, parent, source) {
@@ -110619,7 +111320,7 @@ var SessionManager = class {
110619
111320
  }
110620
111321
  }
110621
111322
  console.error(`[projects] made ${target.path}`);
110622
- const info = await this.addProject(target.path);
111323
+ const info = await this.addProject(target.path, { trusted: !source });
110623
111324
  this.emit({ type: "projects_changed" });
110624
111325
  return info;
110625
111326
  }
@@ -110713,13 +111414,29 @@ var SessionManager = class {
110713
111414
  }
110714
111415
  /**
110715
111416
  * Project trust (M4 A-2, plan decision 3). The app runtime reads it fresh from the store every
110716
- * time, so this only writes it — telling it to rescan is the RPC layer's job (this layer does not
110717
- * know about the runtime).
111417
+ * time, so telling it to rescan is the RPC layer's job (this layer does not know about the runtime).
111418
+ *
111419
+ * Trust decides which setting files a session reads (Claude `settingSources`, Codex `repoFilesConfig`), and a
111420
+ * live process keeps the ones it started with (#663). The flag is part of the launch-settings fingerprint, so a
111421
+ * change goes through the same safety restart as any other launch-settings change (#656): an idle session of the
111422
+ * project restarts now, one in a turn at the end of the turn, background work or not, and the conversation says
111423
+ * why. The flag is written before this returns; the promise settles when the idle restarts are done.
110718
111424
  */
110719
111425
  setProjectTrusted(projectId, trusted) {
110720
111426
  if (!this.store.setProjectTrusted(projectId, trusted)) {
110721
111427
  throw Object.assign(new Error(`Project not found: ${projectId}`), { code: "internal" });
110722
111428
  }
111429
+ if (!trusted) {
111430
+ for (const rec of [...this.agentStarts.values()]) {
111431
+ if (rec.ended || rec.req.projectId !== projectId) continue;
111432
+ this.withdrawAgentStart(
111433
+ rec.req.requestId,
111434
+ `The person stopped trusting this session's project, so no agent was started. ${ASK_THE_PERSON}`,
111435
+ agentStartPersonText(rec.req, "untrusted")
111436
+ );
111437
+ }
111438
+ }
111439
+ return this.refreshLaunchSettings(projectId, true);
110723
111440
  }
110724
111441
  /**
110725
111442
  * Is this project trusted (decision 3, #92) — passed to the adapter when launching a session. False
@@ -111169,6 +111886,7 @@ var SessionManager = class {
111169
111886
  let handle;
111170
111887
  if (params.builderOf) this.setBuilder(params.builderOf, id2);
111171
111888
  const apps = this.appsFor(info);
111889
+ const profile = toolProfileFor(this.profileFacts(info, !!params.builderOf));
111172
111890
  const from = this.handleSink();
111173
111891
  this.unborn.set(id2, []);
111174
111892
  try {
@@ -111192,14 +111910,14 @@ var SessionManager = class {
111192
111910
  ...this.handoffReadDirs(params.projectId, !!params.handoff?.fromSessionId),
111193
111911
  // Paths another project handed back to this session (#371), asked on every read
111194
111912
  mayRead: (path2) => this.mayRead(id2, path2, cwd),
111195
- // An orchestrator gets all of them; an ordinary session the reader set (#320, #561). The same rule runs again
111196
- // on every wake
111197
- orchestratorTools: info.kind === "orchestrator" ? this.orchestratorToolsFor(id2) : params.builderOf ? this.orchestratorToolsFor(id2) : this.readsOwnProject(info) ? this.readerToolsFor(id2) : void 0,
111198
- toolProfile: info.kind === "orchestrator" ? "orchestrator" : params.builderOf ? "builder" : this.readsOwnProject(info) ? "reader" : void 0,
111913
+ // An orchestrator gets all of them, a builder session (M4 C-3) just its own app's check (the bundle blocks
111914
+ // the rest), an ordinary session its own project, read-only (#320, #561)
111915
+ orchestratorTools: this.toolBundleOf(id2, profile, info),
111916
+ toolProfile: profile ?? void 0,
111199
111917
  systemPromptAppend: info.kind === "orchestrator" ? orchestratorRole() + this.skillsPrompt() : info.roleAppend ?? void 0,
111200
111918
  // The path back to the host — used by the orchestrator tools' bridge and an external app's bridge
111201
111919
  // (M4 A-5)
111202
- orchestratorBridge: info.kind === "orchestrator" || params.builderOf || apps || this.readsOwnProject(info) ? this.endpoint?.(id2) ?? void 0 : void 0,
111920
+ orchestratorBridge: profile || apps ? this.endpoint?.(id2) ?? void 0 : void 0,
111203
111921
  // An MCP server the person approved is loaded here as a user-folder app (M4 A-7, decision 4)
111204
111922
  apps,
111205
111923
  // The answer schema an app supplied when it asked for this (M4 D-1) — Claude gets it on this
@@ -111235,6 +111953,7 @@ var SessionManager = class {
111235
111953
  }
111236
111954
  }
111237
111955
  this.handles.set(id2, handle);
111956
+ this.rememberBackgroundTasks(info);
111238
111957
  this.cacheLedger.note(id2, params.resumeExternalId ? "new_process" : "cold_start");
111239
111958
  this.running.set(id2, {
111240
111959
  model: info.model,
@@ -111439,26 +112158,96 @@ var SessionManager = class {
111439
112158
  * The keeper as a `ProcessSource` for one session, when this host runs under one (#280 step 2). `version` is the CLI
111440
112159
  * version a process spawned now is started from, written on its keeper tag (#297).
111441
112160
  */
111442
- processSourceFor(sessionId, tool, adopt, version2) {
112161
+ processSourceFor(sessionId, tool, adopt, version2, kept) {
111443
112162
  const host = this.keeperOpts.processes;
111444
112163
  if (!host) return {};
111445
112164
  return {
111446
112165
  processSource: {
111447
112166
  spawn: (spec) => host.spawn(sessionId, tool, spec, version2 ?? void 0),
111448
- ...adopt ? { adopt: { process: adopt, openCalls: this.openToolCalls(sessionId) } } : {}
112167
+ ...adopt ? { adopt: { process: adopt, openCalls: this.openToolCalls(sessionId), backgroundTasks: kept ?? [] } } : {}
111449
112168
  }
111450
112169
  };
111451
112170
  }
112171
+ /**
112172
+ * The running background tasks of a session's process, kept in the store (#648) so the host that adopts the process
112173
+ * after a swap knows what it holds: the tool lists its level only when it changes, and `close()` on a process kills
112174
+ * its shells silently. Written on every change and when a process starts (empty). `null` when no host ever wrote it
112175
+ * for this session, which an adopted process reads as "unknown".
112176
+ */
112177
+ backgroundTasksKey(sessionId) {
112178
+ return `background_tasks:${sessionId}`;
112179
+ }
112180
+ keptBackgroundTasks(sessionId) {
112181
+ const raw = this.store.appSetting(this.backgroundTasksKey(sessionId));
112182
+ if (raw === null) return null;
112183
+ try {
112184
+ const parsed = JSON.parse(raw);
112185
+ return Array.isArray(parsed) ? parsed.filter((t) => t && typeof t.id === "string" && t.status === "running") : [];
112186
+ } catch {
112187
+ return [];
112188
+ }
112189
+ }
112190
+ rememberBackgroundTasks(m) {
112191
+ this.store.setAppSetting(this.backgroundTasksKey(m.id), JSON.stringify(m.backgroundTasks.filter((t) => t.status === "running")));
112192
+ }
112193
+ /** Background work the session's process holds that ending it would end (#648): what its tool listed as running */
112194
+ backgroundRunning(sessionId) {
112195
+ const m = this.meta.get(sessionId);
112196
+ return !!m && liveBackgroundTasks(m.backgroundTasks).length > 0;
112197
+ }
112198
+ /**
112199
+ * Whether what the session's process holds in the background is known (#648). An adopted process with no record is
112200
+ * not, until its tool lists its work; after `BACKGROUND_UNKNOWN_MAX_MS` without a word from the session it is taken
112201
+ * as holding nothing, said in the log, and recorded so the next host does not start from unknown again.
112202
+ */
112203
+ backgroundKnown(sessionId) {
112204
+ const since = this.backgroundUnknown.get(sessionId);
112205
+ if (since === void 0) return true;
112206
+ if (Date.now() - since < BACKGROUND_UNKNOWN_MAX_MS) return false;
112207
+ this.backgroundUnknown.delete(sessionId);
112208
+ console.error(
112209
+ `[sessions] ${sessionId.slice(0, 8)}: no word of its background work since it was adopted, for ${Math.round(BACKGROUND_UNKNOWN_MAX_MS / 36e5)} h; taking it as none`
112210
+ );
112211
+ const m = this.meta.get(sessionId);
112212
+ if (m) this.rememberBackgroundTasks(m);
112213
+ return true;
112214
+ }
112215
+ /** The session's process is gone for good (deleted): nothing of its background work is kept (#648) */
112216
+ forgetBackground(sessionId) {
112217
+ this.backgroundUnknown.delete(sessionId);
112218
+ this.clearBackgroundHold(sessionId);
112219
+ this.store.deleteAppSetting(this.backgroundTasksKey(sessionId));
112220
+ }
112221
+ /**
112222
+ * Says in the conversation that a restart is about to end the session's background work (#648): the tool's own
112223
+ * ending of each task follows in the list, but a person whose dev server stopped should read why where they look.
112224
+ */
112225
+ noticeBackgroundStopped(sessionId, cause2) {
112226
+ const m = this.meta.get(sessionId);
112227
+ if (!m || !this.handles.has(sessionId)) return;
112228
+ const why = cause2 === "launch_settings" ? "changed launch settings" : cause2 === "instructions" ? "changed Centralu tool definitions" : cause2 === "thread_restart" ? "a change of attached apps" : cause2 === "model_change" ? "a model change" : this.running.get(sessionId)?.permissionPreset !== m.permissionPreset ? "a permission change" : "changed settings";
112229
+ const tasks = liveBackgroundTasks(m.backgroundTasks);
112230
+ const text2 = tasks.length > 0 ? `Restarting the agent to apply ${why}. The background work it was running stops with it: ${tasks.map((t) => t.description).join(", ")}. Ask the agent to start it again if you still need it.` : this.backgroundUnknown.has(sessionId) ? `Restarting the agent to apply ${why}. Any background work it started before Centralu was updated (a dev server, a watcher) stops with it; ask the agent to start it again if you still need it.` : null;
112231
+ if (text2) this.onEvent({ type: "notice", sessionId, level: "warning", from: "Centralu", label: "restart", text: text2 });
112232
+ }
112233
+ /** Says in the conversation that the restart in progress applies a change of the project's trust (#663) */
112234
+ noticeTrustRestart(sessionId) {
112235
+ if (!this.trustRestartNotice.delete(sessionId)) return;
112236
+ const m = this.meta.get(sessionId);
112237
+ if (!m || !this.handles.has(sessionId)) return;
112238
+ const text2 = this.projectTrusted(m.projectId) ? "Restarted the agent to apply this project's new trust: it now reads the project's own settings and instruction files (CLAUDE.md, AGENTS.md)." : "Restarted the agent to apply this project's removed trust: it now reads only your own settings, not the project's files.";
112239
+ this.onEvent({ type: "notice", sessionId, level: "info", from: "Centralu", label: "restart", text: text2 });
112240
+ }
111452
112241
  /** Tool calls the store holds for this session with no result after them (the newest 200 rows) */
111453
112242
  openToolCalls(sessionId) {
111454
- const open5 = /* @__PURE__ */ new Map();
112243
+ const open6 = /* @__PURE__ */ new Map();
111455
112244
  for (const r of this.store.loadMessages(sessionId, 200)) {
111456
112245
  const p = r.payload;
111457
112246
  if (typeof p?.callId !== "string") continue;
111458
- if (r.kind === "tool_call" && typeof p.summary?.tool === "string") open5.set(p.callId, p.summary.tool);
111459
- else if (r.kind === "tool_result") open5.delete(p.callId);
112247
+ if (r.kind === "tool_call" && typeof p.summary?.tool === "string") open6.set(p.callId, p.summary.tool);
112248
+ else if (r.kind === "tool_result") open6.delete(p.callId);
111460
112249
  }
111461
- return [...open5].map(([callId, tool]) => ({ callId, tool }));
112250
+ return [...open6].map(([callId, tool]) => ({ callId, tool }));
111462
112251
  }
111463
112252
  /**
111464
112253
  * Re-attaches the tool processes a previous host left running in the keeper (#280 step 2), so
@@ -111491,7 +112280,7 @@ var SessionManager = class {
111491
112280
  );
111492
112281
  }
111493
112282
  async doResumeSession(sessionId, adopt) {
111494
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112283
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
111495
112284
  const m = this.meta.get(sessionId);
111496
112285
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
111497
112286
  if (this.handles.has(sessionId)) return { session: m, resumed: true };
@@ -111533,6 +112322,7 @@ var SessionManager = class {
111533
112322
  }
111534
112323
  const apps = this.appsFor(m);
111535
112324
  const builds = this.builderRefOf(m) !== null;
112325
+ const profile = toolProfileFor(this.profileFacts(m, builds));
111536
112326
  const from = this.handleSink();
111537
112327
  const launched = {
111538
112328
  model: m.model,
@@ -111542,12 +112332,13 @@ var SessionManager = class {
111542
112332
  permissionPreset: m.permissionPreset
111543
112333
  };
111544
112334
  if (!adopt) m.agentVersion = this.versionHint?.(tool) ?? null;
112335
+ const kept = adopt ? this.keptBackgroundTasks(sessionId) : null;
111545
112336
  try {
111546
112337
  const creating = adapter.createSession(
111547
112338
  {
111548
112339
  sessionId,
111549
112340
  cwd,
111550
- ...this.processSourceFor(sessionId, tool, adopt, m.agentVersion),
112341
+ ...this.processSourceFor(sessionId, tool, adopt, m.agentVersion, kept),
111551
112342
  model: launched.model ?? void 0,
111552
112343
  effort: launched.effort ?? void 0,
111553
112344
  verbosity: launched.verbosity ?? void 0,
@@ -111579,11 +112370,8 @@ var SessionManager = class {
111579
112370
  * has just been attached passes this check the next time it wakes and receives the
111580
112371
  * child-only tools and role.
111581
112372
  */
111582
- orchestratorTools: m.kind === "orchestrator" ? this.orchestratorToolsFor(sessionId) : m.kind === "coordinator" ? this.orchestratorToolsFor(sessionId, m.scopeSessionIds ?? []) : builds ? this.orchestratorToolsFor(sessionId) : (
111583
- // Every other session: its own project (#320, #561)
111584
- this.readsOwnProject(m) ? this.readerToolsFor(sessionId) : void 0
111585
- ),
111586
- toolProfile: m.kind === "orchestrator" ? "orchestrator" : m.kind === "coordinator" ? "scoped" : builds ? "builder" : this.readsOwnProject(m) ? "reader" : void 0,
112373
+ orchestratorTools: this.toolBundleOf(sessionId, profile, m),
112374
+ toolProfile: profile ?? void 0,
111587
112375
  /*
111588
112376
  * This is **where memory is handed over.** Switching tools breaks externalId, which routes
111589
112377
  * things down this path (a new process), and that is when the past conversation rides along
@@ -111599,7 +112387,7 @@ var SessionManager = class {
111599
112387
  // for a builder session (C-2) even if the app is broken
111600
112388
  m.roleAppend ?? void 0
111601
112389
  ),
111602
- orchestratorBridge: m.kind === "orchestrator" || m.kind === "coordinator" || builds || apps || this.readsOwnProject(m) ? this.endpoint?.(sessionId) ?? void 0 : void 0,
112390
+ orchestratorBridge: profile || apps ? this.endpoint?.(sessionId) ?? void 0 : void 0,
111603
112391
  // An approved MCP server is a user-folder app — an orchestrator receives those apps right here (M4
111604
112392
  // A-7)
111605
112393
  apps
@@ -111625,6 +112413,8 @@ var SessionManager = class {
111625
112413
  if (!adopt) {
111626
112414
  this.rememberToolPrefix(sessionId);
111627
112415
  this.rememberLaunchSettings(m, cwd);
112416
+ this.backgroundUnknown.delete(sessionId);
112417
+ this.rememberBackgroundTasks(m);
111628
112418
  }
111629
112419
  this.emit({ type: "session_live", sessionId, live: true });
111630
112420
  this.cacheLedger.note(sessionId, this.restartReasons.get(sessionId) ?? "new_process");
@@ -111635,6 +112425,8 @@ var SessionManager = class {
111635
112425
  if (adopt) {
111636
112426
  this.store.upsertSession(m);
111637
112427
  this.emit({ type: "state_change", sessionId, state: m.state, reason: "reattached" });
112428
+ if (kept === null) this.backgroundUnknown.set(sessionId, Date.now());
112429
+ else if (kept.length > 0) this.onEvent({ type: "background_tasks", sessionId, live: kept });
111638
112430
  } else {
111639
112431
  m.state = "idle";
111640
112432
  m.waitingSince = null;
@@ -111680,7 +112472,7 @@ var SessionManager = class {
111680
112472
  * exactly as it was, and only what happens from here on piles up in the copy.
111681
112473
  */
111682
112474
  async forkConversation(sessionId) {
111683
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112475
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
111684
112476
  const m = this.meta.get(sessionId);
111685
112477
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
111686
112478
  const source = m.externalId ?? m.importedFrom;
@@ -111713,6 +112505,7 @@ var SessionManager = class {
111713
112505
  this.leaving.delete(sessionId);
111714
112506
  }
111715
112507
  }
112508
+ trashing = /* @__PURE__ */ new Map();
111716
112509
  /**
111717
112510
  * Moves a session to the trash (#204) — what deleting a session means now. Nothing is destroyed: the process
111718
112511
  * stops, and the session leaves every list (sidebar, inbox, palette, grid, search, the agents' session tools and
@@ -111732,6 +112525,20 @@ var SessionManager = class {
111732
112525
  * tool, and **+ → Past conversations**, still have it.
111733
112526
  */
111734
112527
  async trashSession(sessionId, removeWorktree = false, removeExternal = false, recovery) {
112528
+ const pending = this.trashing.get(sessionId);
112529
+ if (pending) {
112530
+ if (recovery) throw Object.assign(new Error("The session is already being deleted without its recovery snapshot"), { code: "internal" });
112531
+ return pending;
112532
+ }
112533
+ const deleting = this.trashSessionOnce(sessionId, removeWorktree, removeExternal, recovery);
112534
+ this.trashing.set(sessionId, deleting);
112535
+ try {
112536
+ await deleting;
112537
+ } finally {
112538
+ this.trashing.delete(sessionId);
112539
+ }
112540
+ }
112541
+ async trashSessionOnce(sessionId, removeWorktree, removeExternal, recovery) {
111735
112542
  if (this.worktreeCleanup.has(sessionId) && !recovery) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
111736
112543
  const m = this.meta.get(sessionId);
111737
112544
  if (!m) {
@@ -111756,6 +112563,13 @@ var SessionManager = class {
111756
112563
  }
111757
112564
  this.readGrants.delete(sessionId);
111758
112565
  this.agentConsents.delete(sessionId);
112566
+ for (const rec of [...this.agentStarts.values()]) {
112567
+ if (rec.req.callerId !== sessionId) continue;
112568
+ this.withdrawAgentStart(rec.req.requestId, "The session that asked was deleted.", agentStartPersonText(rec.req, "deleted"));
112569
+ this.agentStarts.delete(rec.req.requestId);
112570
+ }
112571
+ this.updateConsents.delete(sessionId);
112572
+ this.updateApplyConsents.delete(sessionId);
111759
112573
  for (const ask of [...this.hostAsks.values()]) {
111760
112574
  if (ask.sessionId !== sessionId) continue;
111761
112575
  this.hostAsks.delete(ask.requestId);
@@ -111767,14 +112581,18 @@ var SessionManager = class {
111767
112581
  });
111768
112582
  this.handles.delete(sessionId);
111769
112583
  }
112584
+ this.forgetBackground(sessionId);
111770
112585
  this.closeStream(sessionId);
111771
112586
  let project2;
111772
112587
  if (m.projectId) project2 = this.store.listProjects().find((p) => p.id === m.projectId);
111773
112588
  this.running.delete(sessionId);
111774
112589
  this.restartAfterTurn.delete(sessionId);
112590
+ this.trustRestartNotice.delete(sessionId);
111775
112591
  this.prCheckedAt.delete(sessionId);
111776
112592
  this.appsRestartAfterTurn.delete(sessionId);
111777
- this.awaitingReport.delete(sessionId);
112593
+ this.clearReport(sessionId);
112594
+ for (const [child, caller] of [...this.awaitingReport]) if (caller === sessionId) this.clearReport(child);
112595
+ for (const key of [...this.observerNotes.keys()]) if (key.startsWith(`${sessionId}>`) || key.endsWith(`>${sessionId}`)) this.observerNotes.delete(key);
111778
112596
  this.forgetCommits(sessionId);
111779
112597
  this.meta.delete(sessionId);
111780
112598
  await this.store.trashSession(sessionId, {
@@ -111788,6 +112606,8 @@ var SessionManager = class {
111788
112606
  this.appsHub?.sessionGone(sessionId);
111789
112607
  this.emit({ type: "session_deleted", sessionId });
111790
112608
  }
112609
+ /** The startup repair of recovery folders an interrupted purge left aside (#647) */
112610
+ recoveryRepair = Promise.resolve();
111791
112611
  /** Only one trash operation per session at a time — a purge racing a restore would restore half of a deleted session */
111792
112612
  trashBusy = /* @__PURE__ */ new Set();
111793
112613
  async trashOp(sessionId, run) {
@@ -111852,6 +112672,7 @@ var SessionManager = class {
111852
112672
  */
111853
112673
  async restoreSession(sessionId) {
111854
112674
  return this.trashOp(sessionId, async () => {
112675
+ await this.recoveryRepair;
111855
112676
  const t = this.store.trashedSession(sessionId);
111856
112677
  if (!t) throw Object.assign(new Error(`Not in the trash: ${sessionId}`), { code: "session_not_found" });
111857
112678
  const { record: record2 } = t;
@@ -111908,10 +112729,12 @@ var SessionManager = class {
111908
112729
  * believes 550MB is gone). It is skipped when a live session holds the same conversation (it was pulled back
111909
112730
  * from Past conversations meanwhile): deleting it would take that session's history. Then the worktree, when
111910
112731
  * chosen (a failure does not stop the purge — its folder is plain files the person can still remove). Then our
111911
- * rows, and last the attachments and the handoff note, which have to outlive the rows that point at them.
112732
+ * rows, and last the recovery files, the attachments and the handoff note, which have to outlive the rows that
112733
+ * point at them.
111912
112734
  */
111913
112735
  async purgeSession(sessionId) {
111914
112736
  return this.trashOp(sessionId, async () => {
112737
+ await this.recoveryRepair;
111915
112738
  const t = this.store.trashedSession(sessionId);
111916
112739
  if (!t) throw Object.assign(new Error(`Not in the trash: ${sessionId}`), { code: "session_not_found" });
111917
112740
  const { session: s, record: record2 } = t;
@@ -111934,8 +112757,16 @@ var SessionManager = class {
111934
112757
  await gitWorktreeRemove(record2.projectPath, s.worktree.path, true).catch(() => {
111935
112758
  });
111936
112759
  }
111937
- await clearWorktreeRecovery(sessionId);
111938
- await this.store.purgeSession(sessionId);
112760
+ const recoveryRemoval = await stageWorktreeRecoveryRemoval(sessionId);
112761
+ try {
112762
+ await this.store.purgeSession(sessionId);
112763
+ this.forgetBackground(sessionId);
112764
+ } catch (e) {
112765
+ await recoveryRemoval.abort().catch((cause2) => console.error(`[trash] recovery files of ${sessionId.slice(0, 8)} could not be put back after a failed purge and stay aside until the next start: ${cause2.message}`));
112766
+ throw e;
112767
+ }
112768
+ await recoveryRemoval.commit().catch(() => {
112769
+ });
111939
112770
  await clearAttachments(sessionId).catch(() => {
111940
112771
  });
111941
112772
  if (record2.projectId) await this.sweepOrphanHandoffNotes(record2.projectId).catch(() => {
@@ -112006,8 +112837,8 @@ var SessionManager = class {
112006
112837
  const m = this.meta.get(sessionId);
112007
112838
  return live && m && live.model !== m.model ? "model_change" : void 0;
112008
112839
  }
112009
- async updateSettings(sessionId, s) {
112010
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112840
+ async updateSettings(sessionId, s, by = "person") {
112841
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112011
112842
  const m = this.meta.get(sessionId);
112012
112843
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112013
112844
  if (s.model !== void 0 && s.model !== m.model) this.cacheLedger.note(sessionId, "model_change");
@@ -112020,6 +112851,16 @@ var SessionManager = class {
112020
112851
  if ((s.model !== void 0 || s.effort !== void 0) && m.projectId) {
112021
112852
  this.store.setProjectToolDefaults(m.projectId, m.tool, { model: m.model, effort: m.effort });
112022
112853
  }
112854
+ this.emit({
112855
+ type: "settings_changed",
112856
+ sessionId,
112857
+ model: m.model,
112858
+ effort: m.effort,
112859
+ verbosity: m.verbosity,
112860
+ serviceTier: m.serviceTier,
112861
+ permissionPreset: m.permissionPreset,
112862
+ ...by === "person" ? { person: true } : { by }
112863
+ });
112023
112864
  await this.resuming.get(sessionId)?.catch(() => {
112024
112865
  });
112025
112866
  const handle = this.handles.get(sessionId);
@@ -112102,6 +112943,7 @@ var SessionManager = class {
112102
112943
  }
112103
112944
  const wasBusy = inTurn(m.state);
112104
112945
  this.applyStateHint(e, m);
112946
+ if (this.backgroundUnknown.has(m.id)) this.backgroundUnknown.set(m.id, Date.now());
112105
112947
  if (wasBusy && !inTurn(m.state)) {
112106
112948
  endedTurn = true;
112107
112949
  const ref = this.builderRefOf(m);
@@ -112119,6 +112961,7 @@ var SessionManager = class {
112119
112961
  });
112120
112962
  this.handles.delete(e.sessionId);
112121
112963
  this.running.delete(e.sessionId);
112964
+ this.emit({ type: "session_live", sessionId: e.sessionId, live: false, reason: "crashed" });
112122
112965
  }
112123
112966
  }
112124
112967
  }
@@ -112128,13 +112971,37 @@ var SessionManager = class {
112128
112971
  this.emit(seq != null ? { ...e, seq } : e);
112129
112972
  if (endedTurn && e.sessionId && !run?.tracksInputs) void this.reportBackIfAwaited(e.sessionId, e, run?.completedAnswer);
112130
112973
  if (e.type === "approval_resolved" && e.sessionId) this.raiseHostAsks(e.sessionId);
112974
+ if (endedTurn && e.sessionId && this.meta.get(e.sessionId)?.state !== "error") this.raiseHostAsks(e.sessionId);
112131
112975
  if (endedTurn && e.sessionId) this.forgetCommits(e.sessionId);
112132
112976
  if (endedTurn && e.sessionId) this.applyDeferredRestart(e.sessionId);
112977
+ if (e.type === "background_tasks" && e.sessionId) this.applyDeferredRestart(e.sessionId);
112133
112978
  }
112134
- /** A note receipt follows turn_complete: re-check the same deferred change when the last hold is released. */
112979
+ /** A queued note not yet taken, on the delegation's wait or an observer's own reader (#646): the session is not between turns */
112980
+ inputsHeld(sessionId) {
112981
+ if (this.agentRuns.get(sessionId)?.waitingForInputs) return true;
112982
+ for (const observer of this.turnObservers.get(sessionId) ?? []) if (observer.waitingForInputs) return true;
112983
+ return false;
112984
+ }
112985
+ /**
112986
+ * A note receipt follows turn_complete: re-check the same deferred change when the last hold is released.
112987
+ *
112988
+ * Background work (#648). A change of safety (the permission preset, the launch settings, the tool definitions a
112989
+ * profile serves) is applied at the end of the turn whatever runs in the background: another turn under the old
112990
+ * policy is worse than a stopped dev server, and the conversation says what stopped (`noticeBackgroundStopped`). Any
112991
+ * other change (a model, an app set) waits while work the tool listed is running, as a sleep does, and is applied
112992
+ * anyway after `BACKGROUND_HOLD_MAX_MS`.
112993
+ */
112135
112994
  applyDeferredRestart(sessionId) {
112136
112995
  const session2 = this.meta.get(sessionId);
112137
- if (!session2 || inTurn(session2.state) || this.deferredRestarts.has(sessionId) || this.agentRuns.get(sessionId)?.waitingForInputs || !this.restartAfterTurn.delete(sessionId)) return;
112996
+ if (!session2 || inTurn(session2.state) || this.deferredRestarts.has(sessionId) || this.inputsHeld(sessionId) || !this.restartAfterTurn.has(sessionId)) return;
112997
+ const hold = this.backgroundHolds.get(sessionId);
112998
+ const holdExpired = !!hold && Date.now() - hold.since >= BACKGROUND_HOLD_MAX_MS;
112999
+ if (!holdExpired && !this.safetyRestartPending(sessionId) && this.backgroundRunning(sessionId)) {
113000
+ this.holdForBackground(sessionId);
113001
+ return;
113002
+ }
113003
+ this.restartAfterTurn.delete(sessionId);
113004
+ this.clearBackgroundHold(sessionId);
112138
113005
  const apps = this.appsRestartAfterTurn.delete(sessionId);
112139
113006
  const definitions = this.definitionsRestartAfterTurn.delete(sessionId);
112140
113007
  const launchSettings = this.launchSettingsRestartAfterTurn.delete(sessionId);
@@ -112148,6 +113015,28 @@ var SessionManager = class {
112148
113015
  void restarting.catch(() => {
112149
113016
  });
112150
113017
  }
113018
+ /** A deferred restart changes what the process may do (#648): its permission preset, launch settings or tool set */
113019
+ safetyRestartPending(sessionId) {
113020
+ const m = this.meta.get(sessionId);
113021
+ const live = this.running.get(sessionId);
113022
+ return this.launchSettingsRestartAfterTurn.has(sessionId) || this.definitionsRestartAfterTurn.has(sessionId) || !!m && !!live && live.permissionPreset !== m.permissionPreset;
113023
+ }
113024
+ /** Holds a non-safety restart for background work, at most `BACKGROUND_HOLD_MAX_MS` (#648) */
113025
+ holdForBackground(sessionId) {
113026
+ if (this.backgroundHolds.has(sessionId)) return;
113027
+ const timer = setTimeout(() => {
113028
+ console.error(`[sessions] ${sessionId.slice(0, 8)}: a restart waited ${Math.round(BACKGROUND_HOLD_MAX_MS / 6e4)} min for background work; applying it at the next quiet moment`);
113029
+ this.applyDeferredRestart(sessionId);
113030
+ }, BACKGROUND_HOLD_MAX_MS);
113031
+ timer.unref?.();
113032
+ this.backgroundHolds.set(sessionId, { since: Date.now(), timer });
113033
+ }
113034
+ /** The wait is over: the restart was applied, or the session is gone (#648) */
113035
+ clearBackgroundHold(sessionId) {
113036
+ const hold = this.backgroundHolds.get(sessionId);
113037
+ if (hold) clearTimeout(hold.timer);
113038
+ this.backgroundHolds.delete(sessionId);
113039
+ }
112151
113040
  /**
112152
113041
  * Notifies the orchestrator when the work it assigned finishes — **the other half of "one window".**
112153
113042
  *
@@ -112162,8 +113051,8 @@ var SessionManager = class {
112162
113051
  */
112163
113052
  async reportBackIfAwaited(sessionId, ending, answer) {
112164
113053
  const orchestratorId = this.awaitingReport.get(sessionId);
112165
- if (!orchestratorId) return;
112166
- this.awaitingReport.delete(sessionId);
113054
+ if (!orchestratorId || this.handingOver) return;
113055
+ this.clearReport(sessionId);
112167
113056
  const target = this.meta.get(sessionId);
112168
113057
  if (!target || !this.meta.has(orchestratorId)) return;
112169
113058
  const project2 = target.projectId ? this.store.listProjects().find((p) => p.id === target.projectId)?.name ?? "(project no longer exists)" : "(none)";
@@ -112268,6 +113157,8 @@ If you need more, you can read that session's recent conversation with read_sess
112268
113157
  break;
112269
113158
  case "background_tasks":
112270
113159
  m.backgroundTasks = applyBackgroundTasks(m.backgroundTasks, e);
113160
+ if (e.snapshot) this.backgroundUnknown.delete(m.id);
113161
+ if (!this.backgroundUnknown.has(m.id)) this.rememberBackgroundTasks(m);
112271
113162
  break;
112272
113163
  case "agent_version":
112273
113164
  m.agentVersion = e.version;
@@ -112506,7 +113397,7 @@ If you need more, you can read that session's recent conversation with read_sess
112506
113397
  async deliver(sessionId, text2, attachments, from, relayed, fromApp, fromAppVia = "inline", sendInput) {
112507
113398
  const restarting = this.deferredRestarts.get(sessionId);
112508
113399
  if (restarting) await restarting;
112509
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
113400
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112510
113401
  const m = this.meta.get(sessionId);
112511
113402
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112512
113403
  if (!this.handles.has(sessionId)) {
@@ -112517,7 +113408,7 @@ If you need more, you can read that session's recent conversation with read_sess
112517
113408
  });
112518
113409
  }
112519
113410
  }
112520
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
113411
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112521
113412
  const h = this.requireHandle(sessionId);
112522
113413
  this.closeStream(sessionId);
112523
113414
  const seq = this.store.nextSeq(sessionId);
@@ -112535,7 +113426,7 @@ If you need more, you can read that session's recent conversation with read_sess
112535
113426
  ]);
112536
113427
  m.lastSeq = seq;
112537
113428
  m.lastReadSeq = seq;
112538
- if (m.autoNamed && m.name === "New session" && !fromApp) {
113429
+ if (m.autoNamed && m.name === "New session" && !fromApp && !from) {
112539
113430
  m.name = truncate(text2);
112540
113431
  this.emit({ type: "session_title", sessionId, title: m.name, auto: true });
112541
113432
  }
@@ -112572,7 +113463,7 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112572
113463
  * untouched, and only the tool underneath it is swapped.
112573
113464
  */
112574
113465
  async switchTool(sessionId, tool) {
112575
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
113466
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
112576
113467
  const m = this.meta.get(sessionId);
112577
113468
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112578
113469
  if (m.tool === tool) return { ...m, live: this.handles.has(sessionId) };
@@ -112609,8 +113500,9 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112609
113500
  const ask = this.hostAsks.get(requestId);
112610
113501
  if (ask && ask.sessionId === sessionId) {
112611
113502
  this.hostAsks.delete(requestId);
112612
- const keepsAlways = ask.detail.kind === "project_access" || ask.detail.kind === "agent_start" || ask.detail.kind === "centralu_update";
113503
+ const keepsAlways = ask.detail.kind === "project_access" || ask.detail.kind === "agent_start" || ask.detail.kind === "centralu_update" || isCentraluApply(ask.detail);
112613
113504
  const answer = !keepsAlways && decision === "always" ? "allow" : decision;
113505
+ this.restAfterCard(ask);
112614
113506
  this.onEvent({ type: "approval_resolved", sessionId, requestId, decision: answer });
112615
113507
  ask.resolve(ask.detail.kind === "agent_start" && answer === "always" && scope === "project" ? "always_project" : answer);
112616
113508
  const after = this.meta.get(sessionId);
@@ -112786,6 +113678,37 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112786
113678
  * opens would make that click feel slow.
112787
113679
  */
112788
113680
  modelCache = /* @__PURE__ */ new Map();
113681
+ /** One read of a tool's list at a time: a second asker waits on it instead of starting another tool process */
113682
+ modelFetches = /* @__PURE__ */ new Map();
113683
+ /** When a read last failed, so a caller that cannot wait does not start another one at once (#660) */
113684
+ modelFailures = /* @__PURE__ */ new Map();
113685
+ /**
113686
+ * Reads the list once at a time and **keeps the result whenever it arrives**, also after the caller that asked has
113687
+ * stopped waiting: a Codex list that takes 20 seconds is cached for the next call instead of being thrown away at
113688
+ * the caller's bound and read again from scratch (#660). The read has its own ceiling, so it cannot stay pending
113689
+ * and block every later asker.
113690
+ */
113691
+ fetchModels(tool, adapter) {
113692
+ const running = this.modelFetches.get(tool);
113693
+ if (running) return running;
113694
+ const p = withTimeout(adapter.listModels(), MODEL_LIST_READ_MS, `${tool}'s model list`).then(
113695
+ (models) => {
113696
+ this.modelCache.set(tool, { models, at: Date.now() });
113697
+ this.modelFailures.delete(tool);
113698
+ return models;
113699
+ },
113700
+ (err) => {
113701
+ this.modelFailures.set(tool, Date.now());
113702
+ throw err;
113703
+ }
113704
+ );
113705
+ this.modelFetches.set(tool, p);
113706
+ const clear = () => {
113707
+ if (this.modelFetches.get(tool) === p) this.modelFetches.delete(tool);
113708
+ };
113709
+ p.then(clear, clear);
113710
+ return p;
113711
+ }
112789
113712
  async listModels(tool) {
112790
113713
  const adapter = this.adapters.get(tool);
112791
113714
  if (!adapter?.listModels) {
@@ -112794,13 +113717,40 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112794
113717
  const hit = this.modelCache.get(tool);
112795
113718
  if (hit && Date.now() - hit.at < 5 * 6e4) return { supported: true, models: hit.models };
112796
113719
  try {
112797
- const models = await withTimeout(adapter.listModels(), 15e3);
112798
- this.modelCache.set(tool, { models, at: Date.now() });
113720
+ const models = await withTimeout(this.fetchModels(tool, adapter), 15e3);
112799
113721
  return { supported: true, models };
112800
113722
  } catch (err) {
112801
113723
  return { supported: false, reason: err.message, models: hit?.models ?? [] };
112802
113724
  }
112803
113725
  }
113726
+ /**
113727
+ * The list for checking a choice, **never waiting longer than `ms`** (#660). A list we already have, however old,
113728
+ * answers at once (and an old one is refreshed in the background); otherwise one read is started or joined and
113729
+ * waited on for `ms`. Null when there is no list in time: the caller goes on without checking. A read that fails
113730
+ * is not started again for a minute, so a hung Codex does not get a new tool process on every call.
113731
+ *
113732
+ * `fresh` skips the cached list and reads again: the CLI may have been updated since, so a name the cached list
113733
+ * lacks is not refused on its word alone. `cached` says which kind of list came back.
113734
+ */
113735
+ async modelsWithin(tool, ms3, fresh = false) {
113736
+ const adapter = this.adapters.get(tool);
113737
+ if (!adapter?.listModels) return null;
113738
+ const hit = this.modelCache.get(tool);
113739
+ const stale = !hit || Date.now() - hit.at >= 5 * 6e4;
113740
+ if (hit && !stale && !fresh) return { models: hit.models, cached: true };
113741
+ const failedAt = this.modelFailures.get(tool);
113742
+ const failedRecently = failedAt !== void 0 && Date.now() - failedAt < MODEL_LIST_RETRY_MS && !this.modelFetches.has(tool);
113743
+ if (failedRecently) return hit && !fresh ? { models: hit.models, cached: true } : null;
113744
+ const read = this.fetchModels(tool, adapter);
113745
+ read.catch(() => {
113746
+ });
113747
+ if (hit && !fresh) return { models: hit.models, cached: true };
113748
+ try {
113749
+ return { models: await withTimeout(read, ms3), cached: false };
113750
+ } catch {
113751
+ return null;
113752
+ }
113753
+ }
112804
113754
  /** A project's working directory. Used by the terminal to decide its own key (cwd) */
112805
113755
  cwdOfProject(projectId) {
112806
113756
  return this.cwdOf(projectId);
@@ -113237,13 +114187,14 @@ ${String(stderr).slice(0, 2e3)}`
113237
114187
  * reports background work.
113238
114188
  */
113239
114189
  sessionIdle(sessionId) {
113240
- if (this.worktreeCleanup.has(sessionId)) return { idle: false, reason: "turn" };
114190
+ if (this.worktreeCleanupBlocks(sessionId)) return { idle: false, reason: "turn" };
113241
114191
  const m = this.meta.get(sessionId);
113242
114192
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
113243
- if (this.agentRuns.get(sessionId)?.waitingForInputs) return { idle: false, reason: "turn" };
114193
+ if (this.inputsHeld(sessionId)) return { idle: false, reason: "turn" };
113244
114194
  return sessionIdle(m, {
113245
114195
  running: this.handles.has(sessionId),
113246
- reportsBackground: !!this.adapters.get(m.tool)?.capabilities.backgroundTasks
114196
+ // An adopted process whose work the previous host did not record is as silent as a tool that cannot report (#648)
114197
+ reportsBackground: !!this.adapters.get(m.tool)?.capabilities.backgroundTasks && this.backgroundKnown(sessionId)
113247
114198
  });
113248
114199
  }
113249
114200
  /**
@@ -113303,7 +114254,7 @@ ${String(stderr).slice(0, 2e3)}`
113303
114254
  * process.
113304
114255
  */
113305
114256
  async restartSession(sessionId, cause2) {
113306
- if (this.worktreeCleanup.has(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
114257
+ if (this.worktreeCleanupBlocks(sessionId)) throw Object.assign(new Error("The session worktree is being removed"), { code: "internal" });
113307
114258
  if (cause2) {
113308
114259
  this.restartReasons.set(sessionId, cause2);
113309
114260
  if (cause2 === "launch_settings") console.error(`[sessions] restarting ${sessionId}: launch settings changed or previously unrecorded`);
@@ -113312,8 +114263,11 @@ ${String(stderr).slice(0, 2e3)}`
113312
114263
  this.appsRestartAfterTurn.delete(sessionId);
113313
114264
  this.definitionsRestartAfterTurn.delete(sessionId);
113314
114265
  this.launchSettingsRestartAfterTurn.delete(sessionId);
114266
+ this.noticeTrustRestart(sessionId);
114267
+ this.clearBackgroundHold(sessionId);
113315
114268
  const h = this.handles.get(sessionId);
113316
114269
  if (h) {
114270
+ this.noticeBackgroundStopped(sessionId, cause2);
113317
114271
  await h.dispose().catch(() => {
113318
114272
  });
113319
114273
  this.stampExternalSynced(sessionId);
@@ -113321,7 +114275,17 @@ ${String(stderr).slice(0, 2e3)}`
113321
114275
  this.running.delete(sessionId);
113322
114276
  this.closeStream(sessionId);
113323
114277
  }
113324
- return this.resumeSession(sessionId);
114278
+ const dropped = (reason) => {
114279
+ if (!h || this.handles.has(sessionId)) return;
114280
+ console.error(`[sessions] restart of ${sessionId.slice(0, 8)} did not bring a process up: ${reason}`);
114281
+ this.emit({ type: "session_live", sessionId, live: false, reason: "restart_failed" });
114282
+ };
114283
+ const r = await this.resumeSession(sessionId).catch((err) => {
114284
+ dropped(err.message);
114285
+ throw err;
114286
+ });
114287
+ if (!r.resumed) dropped(r.reason ?? "unknown");
114288
+ return r;
113325
114289
  }
113326
114290
  /** The persisted prefix a process was started with. It is separate from SessionInfo: a screen never needs it. */
113327
114291
  toolPrefixKey(sessionId) {
@@ -113393,8 +114357,9 @@ ${String(stderr).slice(0, 2e3)}`
113393
114357
  * environment). The adapter owns the fingerprint; the host only gives it the same safe
113394
114358
  * boundary behaviour as changed tool definitions.
113395
114359
  */
113396
- async refreshLaunchSettings() {
113397
- for (const session2 of this.meta.values()) {
114360
+ async refreshLaunchSettings(projectId, trustChange = false) {
114361
+ for (const session2 of [...this.meta.values()]) {
114362
+ if (projectId !== void 0 && session2.projectId !== projectId) continue;
113398
114363
  const cwd = this.store.sessionCwd(session2.id);
113399
114364
  if (!cwd) continue;
113400
114365
  const next = this.launchSettingsFingerprint(session2, cwd);
@@ -113403,6 +114368,7 @@ ${String(stderr).slice(0, 2e3)}`
113403
114368
  this.store.setAppSetting(this.launchSettingsKey(session2.id), next);
113404
114369
  continue;
113405
114370
  }
114371
+ if (trustChange) this.trustRestartNotice.add(session2.id);
113406
114372
  if (inTurn(session2.state)) {
113407
114373
  this.restartAfterTurn.add(session2.id);
113408
114374
  this.launchSettingsRestartAfterTurn.add(session2.id);
@@ -113427,7 +114393,7 @@ ${String(stderr).slice(0, 2e3)}`
113427
114393
  const off = () => !this.uiPreferences().sessionTools;
113428
114394
  const offError = "Turned off in Settings";
113429
114395
  return {
113430
- updateCentralu: async (action) => off() ? { ok: false, error: offError } : this.updateCentraluAsked(sessionId, action),
114396
+ updateCentralu: async (action, signal) => off() ? { ok: false, error: offError } : this.updateCentraluAsked(sessionId, action, signal),
113431
114397
  listSessions: async () => off() ? [] : view2.listSessions(),
113432
114398
  readSession: async (...a) => off() ? { ok: false, error: offError } : view2.readSession(...a),
113433
114399
  recall: async (...a) => off() ? { hits: [] } : view2.recall(...a),
@@ -113473,7 +114439,7 @@ ${String(stderr).slice(0, 2e3)}`
113473
114439
  );
113474
114440
  const scopeError = (id2) => scopeIds !== void 0 ? `Not a member of this coordinating session: ${id2}` : ownProject ? `Not a session in this project: ${id2}` : hubView ? `Not a session this machine shares: ${id2}` : `Not a session this app manages: ${id2}`;
113475
114441
  return {
113476
- updateCentralu: async (action) => this.updateCentraluAsked(orchestratorId, action),
114442
+ updateCentralu: async (action, signal) => this.updateCentraluAsked(orchestratorId, action, signal),
113477
114443
  listSessions: async () => {
113478
114444
  const byId = projects();
113479
114445
  const mine = [...this.meta.values()].filter((s) => s.id !== orchestratorId && inScope(s)).map((s) => ({
@@ -113506,15 +114472,7 @@ ${String(stderr).slice(0, 2e3)}`
113506
114472
  const target = this.meta.get(sessionId);
113507
114473
  if (!target || !inScope(target)) return { ok: false, error: scopeError(sessionId) };
113508
114474
  try {
113509
- const info = await this.updateSettings(sessionId, s);
113510
- this.emit({
113511
- type: "settings_changed",
113512
- sessionId,
113513
- model: info.model,
113514
- effort: info.effort,
113515
- verbosity: info.verbosity,
113516
- serviceTier: info.serviceTier
113517
- });
114475
+ const info = await this.updateSettings(sessionId, s, "orchestrator");
113518
114476
  return { ok: true, ...info.applied === "after_turn" ? { deferred: true } : {} };
113519
114477
  } catch (e) {
113520
114478
  return { ok: false, error: e.message };
@@ -113670,8 +114628,8 @@ ${String(stderr).slice(0, 2e3)}`
113670
114628
  sessionId: orchestratorId,
113671
114629
  name: orch?.name ?? "Orchestrator"
113672
114630
  });
113673
- if (reportBack) this.awaitingReport.set(sessionId, orchestratorId);
113674
- else if (this.awaitingReport.get(sessionId) === orchestratorId) this.awaitingReport.delete(sessionId);
114631
+ if (reportBack) this.markReport(sessionId, orchestratorId);
114632
+ else if (this.awaitingReport.get(sessionId) === orchestratorId) this.clearReport(sessionId);
113675
114633
  return { ok: true };
113676
114634
  } catch (e) {
113677
114635
  return { ok: false, error: e.message };
@@ -113936,15 +114894,19 @@ ${String(stderr).slice(0, 2e3)}`
113936
114894
  if (boundary) console.error(`[permissions] auto session ${sessionId}: ${boundary} allowed`);
113937
114895
  return true;
113938
114896
  }
113939
- /** Auto allows and logs; other presets raise a host card, withdrawn if the signal fires. */
113940
- askHost(sessionId, detail, signal) {
114897
+ /**
114898
+ * Auto allows and logs; other presets raise a host card, withdrawn if the signal fires. `requestId` is given when the
114899
+ * caller tracks the card itself (a start_agent request, #673); `shown` raises one the conversation already records
114900
+ * (a request carried over from the previous host) without recording it again.
114901
+ */
114902
+ askHost(sessionId, detail, signal, card = {}) {
113941
114903
  if (signal?.aborted) return Promise.resolve(null);
113942
114904
  const note = detail.kind === "project_access" && !detail.to.trusted ? " (target not trusted)" : "";
113943
114905
  if (this.autoBoundaryOpen(sessionId, `${detail.kind}${note}`)) return Promise.resolve("allow");
113944
114906
  return new Promise((resolve12) => {
113945
114907
  if (signal?.aborted) return resolve12(null);
113946
- const requestId = `${detail.kind === "project_access" ? "xp" : detail.kind === "agent_start" ? "ag" : detail.kind === "centralu_update" ? "up" : "mk"}-${randomUUID10()}`;
113947
- this.hostAsks.set(requestId, { requestId, sessionId, detail, shown: false, resolve: resolve12 });
114908
+ const requestId = card.requestId ?? `${detail.kind === "project_access" ? "xp" : detail.kind === "agent_start" ? "ag" : detail.kind === "centralu_update" || isCentraluApply(detail) ? "up" : "mk"}-${randomUUID10()}`;
114909
+ this.hostAsks.set(requestId, { requestId, sessionId, detail, shown: card.shown ?? false, resolve: resolve12 });
113948
114910
  signal?.addEventListener("abort", () => {
113949
114911
  if (!this.hostAsks.delete(requestId)) return;
113950
114912
  const m = this.meta.get(sessionId);
@@ -114141,17 +115103,20 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114141
115103
  this.delegations.set(key, d2);
114142
115104
  return d2;
114143
115105
  }
114144
- /** Read an existing turn without claiming it in delegations, agentRuns or the caller's sleep graph. */
114145
- observeTurn(child, subscribe) {
114146
- const active = this.agentRuns.get(child.id);
115106
+ /**
115107
+ * Read an existing turn without claiming it in delegations, agentRuns or the caller's sleep graph. With `own`, a
115108
+ * fresh reader even while another caller's wait is active: a note's receipt is held on it, never on that wait (#646).
115109
+ */
115110
+ observeTurn(child, subscribe, own = false) {
115111
+ const active = own ? void 0 : this.agentRuns.get(child.id);
114147
115112
  const shared = active && !active.finished ? active : void 0;
114148
- const wait = shared ?? new AgentRunWait(
114149
- () => {
114150
- },
114151
- () => child.name,
114152
- void 0,
114153
- () => finalAnswer(this.store.loadMessages(child.id, 50))
114154
- );
115113
+ let answerAfter = this.meta.get(child.id)?.lastSeq ?? 0;
115114
+ const wait = shared ?? new AgentRunWait(() => {
115115
+ }, () => child.name, void 0, own ? () => {
115116
+ const answer = finalAnswer(this.store.loadMessages(child.id, 50).filter((m) => m.seq > answerAfter));
115117
+ answerAfter = this.meta.get(child.id)?.lastSeq ?? answerAfter;
115118
+ return answer;
115119
+ } : () => finalAnswer(this.store.loadMessages(child.id, 50)));
114155
115120
  const project2 = this.store.listProjects().find((p) => p.id === child.projectId);
114156
115121
  const target = { id: child.projectId ?? "", name: project2?.name ?? "", path: this.store.sessionCwd(child.id) ?? project2?.path ?? "" };
114157
115122
  if (!shared && subscribe) {
@@ -114249,7 +115214,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114249
115214
  * 1. Who may: an ordinary session of a trusted project (the rule ask_project and attach_app follow, #382), and
114250
115215
  * not one that was itself started or asked by another session (`askedBy`): depth one.
114251
115216
  * 2. How many: at most `AGENT_CEILING` children run at once in a project, over all its sessions.
114252
- * 3. The person's consent (`ensureAgentConsent`): once, always for this session, or always for this project.
115217
+ * 3. The person's consent (`ensureAgentConsent`): the project's trust for a `normal` caller (#673); otherwise a
115218
+ * card, answered once, always for this session, or always for this project.
114253
115219
  * 4. The child: a visible session in the caller's folder, marked `askedBy` the caller, on the tool, model and
114254
115220
  * effort asked for (or the project's own, as a session the person opens there), under **the caller's own
114255
115221
  * permission preset**: never more than the caller has, and nothing the caller can change afterwards.
@@ -114258,49 +115224,184 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114258
115224
  * "working" and the caller is told when the turn ends (the reportBack path); agent_session waits again.
114259
115225
  */
114260
115226
  async startAgent(callerId, opts, signal) {
114261
- const caller = this.meta.get(callerId);
114262
- if (!caller?.projectId) return { ok: false, error: "This session belongs to no project, so it cannot start agents." };
114263
- if ((caller.askedBy || [...this.delegations.values()].some((d8) => d8.sessionId === callerId && !d8.agent && !d8.settled)) && !this.autoBoundaryOpen(callerId, "delegation depth")) {
114264
- return { ok: false, error: "This session was itself started or asked by another session, so it cannot start agents \u2014 say in your answer what is needed instead." };
115227
+ const startedAt = Date.now();
115228
+ const progress = { stage: "checking" };
115229
+ const ctl = new AbortController();
115230
+ const onAbort = () => ctl.abort();
115231
+ signal?.addEventListener("abort", onAbort, { once: true });
115232
+ if (signal?.aborted) ctl.abort();
115233
+ let timer;
115234
+ const late = new Promise((resolve12) => {
115235
+ timer = setTimeout(() => {
115236
+ const standing = progress.stage === "asking" && progress.requestId ? this.agentStarts.get(progress.requestId) : void 0;
115237
+ if (standing && !standing.ended) {
115238
+ standing.detached = true;
115239
+ resolve12(agentStartPending(standing.req));
115240
+ return;
115241
+ }
115242
+ ctl.abort();
115243
+ const d8 = progress.stage === "created" ? progress.d : void 0;
115244
+ if (d8) {
115245
+ const notice2 = [progress.unchecked, this.handles.get(d8.sessionId)?.delegationNotice?.()].filter(Boolean).join("; ");
115246
+ const working = this.agentWorking(d8);
115247
+ resolve12(working.ok && notice2 ? { ...working, notice: [working.notice, notice2].filter(Boolean).join("; ") } : working);
115248
+ } else resolve12(startAgentLate(progress));
115249
+ }, START_AGENT_SETUP_MS);
115250
+ });
115251
+ let set3;
115252
+ const setup = this.setUpAgent(callerId, opts, ctl.signal, progress);
115253
+ try {
115254
+ set3 = await Promise.race([setup, late.then((r) => ({ done: r }))]);
115255
+ } finally {
115256
+ clearTimeout(timer);
115257
+ signal?.removeEventListener("abort", onAbort);
115258
+ }
115259
+ if ("done" in set3) {
115260
+ void setup.then((r) => {
115261
+ if ("key" in r) this.agentWorking(r.d);
115262
+ }, () => {
115263
+ });
115264
+ return set3.done;
114265
115265
  }
115266
+ const { key, d: d2, notice } = set3;
115267
+ const left = Math.max(1e3, START_AGENT_CALL_MS - (Date.now() - startedAt));
115268
+ const result = opts.wait === false ? this.agentWorking(d2) : await this.awaitAgent(key, d2, signal, false, Math.min(ASK_WAIT_MS, left));
115269
+ return result.ok && notice ? { ...result, notice: [result.notice, notice].filter(Boolean).join("; ") } : result;
115270
+ }
115271
+ /** Everything start_agent does before it waits on the child's turn (#660); `done` is a final answer, otherwise the child works */
115272
+ async setUpAgent(callerId, opts, signal, progress) {
115273
+ const done = (r5) => ({ done: r5 });
115274
+ const caller = this.meta.get(callerId);
115275
+ if (!caller?.projectId) return done({ ok: false, error: "This session belongs to no project, so it cannot start agents." });
115276
+ if (this.agentDepthBlocked(caller) && !this.autoBoundaryOpen(callerId, "delegation depth")) return done({ ok: false, error: AGENT_DEPTH_ERROR });
114266
115277
  const project2 = this.store.listProjects().find((p) => p.id === caller.projectId);
114267
- if (!project2) return { ok: false, error: "This session's project is no longer registered." };
115278
+ if (!project2) return done({ ok: false, error: "This session's project is no longer registered." });
114268
115279
  if (project2.trusted !== true && !this.autoBoundaryOpen(callerId, "start in an untrusted project")) {
114269
- return { ok: false, error: "This session's project is not trusted, so it cannot start agents \u2014 the person can trust it from the project menu." };
115280
+ return done({ ok: false, error: `This session's project is not trusted, so it cannot start agents \u2014 the person can trust it from the project menu. ${ASK_THE_PERSON}` });
114270
115281
  }
115282
+ const waiting = [...this.agentStarts.values()].find(
115283
+ (r5) => !r5.ended && r5.req.callerId === callerId && r5.req.task === opts.task && (!opts.tool || r5.req.tool === opts.tool)
115284
+ );
115285
+ if (waiting) return done(agentStartPending(waiting.req));
114271
115286
  const tool = opts.tool ?? this.firstTool(project2.defaultTool);
114272
- if (!tool) return { ok: false, error: "No agent adapter is available. Install and configure an agent CLI before starting an agent." };
115287
+ if (!tool) return done({ ok: false, error: "No agent adapter is available. Install and configure an agent CLI before starting an agent." });
114273
115288
  const adapter = this.adapters.get(tool);
114274
- if (!adapter) return { ok: false, error: `No agent named "${tool}" here. Installed tools: ${[...this.adapters.keys()].join(", ") || "none"}.` };
115289
+ if (!adapter) return done({ ok: false, error: `No agent named "${tool}" here. Installed tools: ${[...this.adapters.keys()].join(", ") || "none"}.` });
114275
115290
  const full = this.agentCeilingError(callerId, project2.id);
114276
- if (full) return { ok: false, error: full };
115291
+ if (full) return done({ ok: false, error: full });
114277
115292
  const remembered = this.store.projectToolDefaults(project2.id)[tool];
114278
115293
  const model = opts.model ?? remembered?.model ?? void 0;
114279
115294
  const effort = opts.effort ?? (!opts.model || opts.model === remembered?.model ? remembered?.effort ?? void 0 : void 0);
114280
- const wrong = await this.agentChoiceError(tool, opts.model, opts.effort, model);
114281
- if (wrong) return { ok: false, error: wrong };
115295
+ const checked = await this.agentChoiceError(tool, opts.model, opts.effort, model);
115296
+ if (checked.error) return done({ ok: false, error: checked.error });
115297
+ const unchecked = checked.unchecked;
115298
+ progress.unchecked = unchecked;
114282
115299
  const found = await adapter.detect();
114283
- if (!found.installed || !found.loggedIn) return { ok: false, error: `${adapter.descriptor.label} cannot take the task: ${found.detail}` };
115300
+ if (!found.installed || !found.loggedIn) return done({ ok: false, error: `${adapter.descriptor.label} cannot take the task: ${found.detail}. ${ASK_THE_PERSON}` });
114284
115301
  const worktree = opts.worktree || opts.branch ? await this.agentWorktreeBranch(project2, opts) : null;
114285
- if (worktree && "error" in worktree) return { ok: false, error: worktree.error };
114286
- const consent = await this.ensureAgentConsent(
114287
- callerId,
114288
- {
114289
- kind: "agent_start",
114290
- project: { id: project2.id, name: project2.name },
114291
- tool: adapter.descriptor.label,
114292
- ...model ? { model } : {},
114293
- preset: caller.permissionPreset,
114294
- ...worktree ? { worktree: { branch: worktree.branch } } : {},
114295
- text: taskLine(opts.task)
114296
- },
114297
- signal
114298
- );
114299
- if (!consent.ok) return consent;
115302
+ if (worktree && "error" in worktree) return done({ ok: false, error: worktree.error });
115303
+ progress.stage = "asking";
115304
+ const detail = {
115305
+ kind: "agent_start",
115306
+ project: { id: project2.id, name: project2.name },
115307
+ tool: adapter.descriptor.label,
115308
+ ...model ? { model } : {},
115309
+ preset: caller.permissionPreset,
115310
+ ...worktree ? { worktree: { branch: worktree.branch } } : {},
115311
+ text: taskLine(opts.task)
115312
+ };
115313
+ const choice = {
115314
+ tool,
115315
+ ...model ? { model } : {},
115316
+ ...effort ? { effort } : {},
115317
+ ...worktree ? { worktreeBranch: worktree.branch } : {},
115318
+ task: opts.task,
115319
+ ...unchecked ? { unchecked } : {}
115320
+ };
115321
+ let request;
115322
+ const consent = await this.ensureAgentConsent(callerId, detail, signal, (requestId) => {
115323
+ request = { requestId, callerId, projectId: project2.id, ...choice, detail };
115324
+ this.openAgentStart(request);
115325
+ progress.requestId = requestId;
115326
+ });
115327
+ if (!consent.ok) {
115328
+ if (request && consent.why === "withdrawn" && (this.handingOver || this.disposing)) return done(agentStartPending(request));
115329
+ if (request) {
115330
+ const ended = this.agentStarts.get(request.requestId)?.ended;
115331
+ if (ended && "error" in ended) return done({ ok: false, error: ended.error });
115332
+ const how = consent.why === "denied" ? "denied" : signal.aborted ? "cancelled" : "withdrawn";
115333
+ this.endAgentStart(request.requestId, { error: consent.error, person: agentStartPersonText(request, how), denied: consent.why === "denied" });
115334
+ }
115335
+ return done(consent);
115336
+ }
115337
+ if (signal.aborted) {
115338
+ const error51 = "The call was withdrawn before the session was created, so none was.";
115339
+ if (request) this.endAgentStart(request.requestId, { error: error51, person: agentStartPersonText(request, "cancelled") });
115340
+ return done({ ok: false, error: error51 });
115341
+ }
115342
+ const r = await this.createAgentChild(callerId, { id: project2.id, name: project2.name, path: project2.path }, choice, progress, request?.detail.preset);
115343
+ if (request) {
115344
+ if ("key" in r) this.endAgentStart(request.requestId, { childId: r.d.sessionId });
115345
+ else {
115346
+ const error51 = r.done.ok ? "No session was created." : r.done.error;
115347
+ this.endAgentStart(request.requestId, { error: error51, person: agentStartPersonText(request, "failed", error51) });
115348
+ }
115349
+ }
115350
+ return r;
115351
+ }
115352
+ /**
115353
+ * The child itself, once the person (or the project's trust) allowed it (#561, #673): counted again, made in the
115354
+ * caller's folder or a new worktree, named, and given the task. A card may have stood for hours, so what the start
115355
+ * rests on is read again first (#673 review): the caller is still here, its project still trusted, it still may start
115356
+ * agents (depth one), and its preset is no higher than the one the person read on the card (`approvedPreset`).
115357
+ *
115358
+ * **The place is taken before the first wait** (#673 review): the ceiling is checked and a place reserved in one
115359
+ * synchronous step (`agentStartReserved`), held until the child's delegation counts it or the start fails. Checked
115360
+ * and counted on either side of `createSession`, eight calls at once all passed a ceiling of four.
115361
+ */
115362
+ async createAgentChild(callerId, project2, choice, progress, approvedPreset) {
115363
+ const done = (r) => ({ done: r });
115364
+ const caller = this.meta.get(callerId);
115365
+ if (!caller) return done({ ok: false, error: "The session that asked is no longer here." });
115366
+ const now = this.store.listProjects().find((p) => p.id === project2.id);
115367
+ if (!now) return done({ ok: false, error: "This session's project is no longer registered." });
115368
+ const auto = caller.permissionPreset === "auto";
115369
+ if (now.trusted !== true && !auto) {
115370
+ return done({ ok: false, error: `This session's project is no longer trusted, so it cannot start agents. ${ASK_THE_PERSON}` });
115371
+ }
115372
+ if (this.agentDepthBlocked(caller) && !auto) return done({ ok: false, error: AGENT_DEPTH_ERROR });
115373
+ if (approvedPreset && !presetWithin(caller.permissionPreset, approvedPreset)) {
115374
+ return done({
115375
+ ok: false,
115376
+ error: `This session's permissions were raised to ${caller.permissionPreset} after the person allowed a session with ${approvedPreset}, so none was started. Call start_agent again if it is still needed: the person decides on the new permissions. ${ASK_THE_PERSON}`
115377
+ });
115378
+ }
115379
+ const { tool, model, effort, unchecked } = choice;
115380
+ const worktree = choice.worktreeBranch ? { branch: choice.worktreeBranch } : null;
114300
115381
  const fullNow = this.agentCeilingError(callerId, project2.id);
114301
- if (fullNow) return { ok: false, error: fullNow };
115382
+ if (fullNow) return done({ ok: false, error: fullNow });
115383
+ this.agentStartReserved.set(project2.id, (this.agentStartReserved.get(project2.id) ?? 0) + 1);
115384
+ let reserved = true;
115385
+ const release = () => {
115386
+ if (!reserved) return;
115387
+ reserved = false;
115388
+ const left = (this.agentStartReserved.get(project2.id) ?? 1) - 1;
115389
+ if (left > 0) this.agentStartReserved.set(project2.id, left);
115390
+ else this.agentStartReserved.delete(project2.id);
115391
+ };
115392
+ try {
115393
+ return await this.createReservedChild(callerId, caller, project2, { tool, model, effort, unchecked, task: choice.task }, worktree, progress, release);
115394
+ } finally {
115395
+ release();
115396
+ }
115397
+ }
115398
+ /** The rest of `createAgentChild`, with its place reserved; `release` once the delegation counts the child */
115399
+ async createReservedChild(callerId, caller, project2, choice, worktree, progress, release) {
115400
+ const done = (r) => ({ done: r });
115401
+ const { tool, model, effort, unchecked } = choice;
114302
115402
  const cwd = worktree ? project2.path : this.store.sessionCwd(callerId) ?? caller.worktree?.path ?? project2.path;
114303
115403
  let child;
115404
+ progress.stage = "creating";
114304
115405
  try {
114305
115406
  child = await this.createSession({
114306
115407
  projectId: project2.id,
@@ -114318,21 +115419,24 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114318
115419
  ...worktree ? { worktree: true, worktreeBranch: worktree.branch, ...caller.worktree ? { worktreeBase: caller.worktree.branch } : {} } : {}
114319
115420
  });
114320
115421
  } catch (e) {
114321
- return { ok: false, error: `Could not start the session: ${e.message}` };
115422
+ return done({ ok: false, error: `Could not start the session: ${e.message}` });
114322
115423
  }
114323
- if (!worktree) this.rename(child.id, agentName(opts.task));
115424
+ progress.stage = "created";
115425
+ progress.childId = child.id;
115426
+ if (!worktree) this.rename(child.id, agentName(choice.task));
114324
115427
  const key = `${callerId}>${child.id}`;
114325
115428
  const d2 = this.startDelegation(key, callerId, child.id, { id: project2.id, name: project2.name, path: child.worktree?.path ?? cwd }, true);
115429
+ release();
115430
+ progress.d = d2;
114326
115431
  try {
114327
- await this.deliver(child.id, agentFrame(labelOf(this.store, caller), opts.task), void 0, { sessionId: callerId, name: labelOf(this.store, caller) }, false);
115432
+ await this.deliver(child.id, agentFrame(labelOf(this.store, caller), choice.task), void 0, { sessionId: callerId, name: labelOf(this.store, caller) }, false);
114328
115433
  } catch (e) {
114329
115434
  this.delegations.delete(key);
114330
115435
  if (this.agentRuns.get(child.id) === d2.wait) this.agentRuns.delete(child.id);
114331
- return { ok: false, error: `Could not give the task to the new session: ${e.message}` };
115436
+ return done({ ok: false, error: `Could not give the task to the new session: ${e.message}` });
114332
115437
  }
114333
115438
  const notice = this.handles.get(child.id)?.delegationNotice?.();
114334
- const result = opts.wait === false ? this.agentWorking(d2) : await this.awaitAgent(key, d2, signal);
114335
- return result.ok && notice ? { ...result, notice: [result.notice, notice].filter(Boolean).join("; ") } : result;
115439
+ return { key, d: d2, notice: [unchecked, notice].filter(Boolean).join("; ") || void 0 };
114336
115440
  }
114337
115441
  /**
114338
115442
  * agent_session (#561): safe/normal work only with their own children; auto opens ownership within the project.
@@ -114342,19 +115446,34 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114342
115446
  * send gives it more, including during the caller's turn on adapters that accept mid-turn input; refused
114343
115447
  * while it works on the person's own message or has a higher preset than the caller
114344
115448
  * stop stops the turn the caller gave it; a turn the person started there is theirs
114345
- * delete sets aside local work in the trash, then removes an idle child's worktree and branch (#608)
115449
+ * delete trashes a child without a worktree, stopping it first; worktree cleanup requires an idle child (#608)
114346
115450
  */
114347
115451
  async agentSession(callerId, opts, signal) {
114348
115452
  if (opts.sessionId === callerId && opts.action !== "status") {
114349
115453
  return { ok: false, error: "agent_session cannot wait on, stop, delete or send to its own calling session." };
114350
115454
  }
115455
+ const request = this.agentStarts.get(opts.sessionId);
115456
+ if (request && request.req.callerId === callerId) {
115457
+ const r = await this.agentStartSession(request, opts.action, signal);
115458
+ if ("ok" in r) return r;
115459
+ opts = { ...opts, sessionId: r.childId, action: r.action };
115460
+ } else if (opts.sessionId.startsWith("ag-") && !this.meta.has(opts.sessionId)) {
115461
+ return {
115462
+ ok: false,
115463
+ error: `No start_agent request ${opts.sessionId} of this session is open here: it ended before Centralu restarted, or it is not this session's. read_session (without a sessionId) lists the sessions.`
115464
+ };
115465
+ }
114351
115466
  const caller = this.meta.get(callerId);
114352
115467
  const child = this.meta.get(opts.sessionId);
114353
- if (!caller?.projectId || !child || child.askedBy !== callerId && !this.autoBoundaryOpen(callerId, "session ownership") || child.projectId !== caller.projectId) {
115468
+ const deletingWithoutWorktree = opts.action === "delete" && !child?.worktree;
115469
+ const startedHere = child?.askedBy === callerId || deletingWithoutWorktree && child?.parentSessionId === callerId;
115470
+ if (!caller?.projectId || !child || !startedHere && (deletingWithoutWorktree || !this.autoBoundaryOpen(callerId, "session ownership")) || child.projectId !== caller.projectId) {
114354
115471
  return { ok: false, error: `${opts.sessionId} is not a session this session started. agent_session works only on sessions start_agent started here.` };
114355
115472
  }
114356
115473
  if (opts.sessionId === callerId) return this.agentAtRest(child);
114357
115474
  const key = `${callerId}>${child.id}`;
115475
+ const note = this.delegations.has(key) ? void 0 : this.observerNotes.get(key);
115476
+ if (note && (opts.action === "status" || opts.action === "wait")) return this.readObserverNote(key, note, opts.action === "wait", signal);
114358
115477
  let d2 = this.delegations.get(key);
114359
115478
  let releaseObservation;
114360
115479
  if (!d2 && this.autoBoundaryOpen(callerId)) {
@@ -114376,13 +115495,22 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114376
115495
  return d2 ? await this.awaitAgent(key, d2, signal, observing) : this.agentAtRest(child);
114377
115496
  case "stop": {
114378
115497
  if (!d2 || d2.settled) return { ok: false, error: `Nothing you gave "${child.name}" is running.` };
114379
- if (!observing && this.awaitingReport.get(child.id) === callerId) this.awaitingReport.delete(child.id);
115498
+ if (!observing && this.awaitingReport.get(child.id) === callerId) this.clearReport(child.id);
114380
115499
  d2.stop("the session that started it stopped it", observing);
114381
115500
  await d2.outcome;
114382
115501
  if (!observing) this.delegations.delete(key);
114383
115502
  return { ok: true, state: "stopped", sessionId: child.id, sessionName: child.name, tool: child.tool };
114384
115503
  }
114385
115504
  case "delete": {
115505
+ if (!child.worktree) {
115506
+ const busy = child.state === "working" || child.state === "waiting_approval" || !!child.pendingApproval || child.pendingQuestions.length > 0 || this.agentRuns.has(child.id) && !this.agentRuns.get(child.id).finished;
115507
+ if (!this.trashing.has(child.id) && busy && (!d2 || d2.settled || d2.callerId !== callerId)) {
115508
+ return { ok: false, error: `"${child.name}" is busy with something else; the person may be using it. Wait for that turn and its pending approval or question to finish before deleting it.` };
115509
+ }
115510
+ await this.trashSession(child.id);
115511
+ if (d2) this.delegations.delete(key);
115512
+ return { ok: true, state: "deleted", sessionId: child.id, sessionName: child.name, tool: child.tool };
115513
+ }
114386
115514
  if (d2 && !d2.settled) return { ok: false, error: `"${child.name}" is still working on what you gave it \u2014 stop it or wait first.` };
114387
115515
  const r = await this.cleanUpWorktreeChild(child);
114388
115516
  if (!r.ok) return r;
@@ -114402,7 +115530,12 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114402
115530
  if (!this.adapters.get(child.tool)?.capabilities.midTurnInput || !this.handles.get(child.id)?.sendTracked) {
114403
115531
  return { ok: false, error: `"${child.name}" cannot take messages while working. Wait for it (action "wait") or stop it first.` };
114404
115532
  }
114405
- const releaseHold = d2.wait.holdInput();
115533
+ const own = observing ? this.observeTurn(child, true, true) : void 0;
115534
+ if (own) void own.run.wait.done.then(own.release, own.release);
115535
+ const record2 = own ? { run: own.run, waiters: 0, told: false } : void 0;
115536
+ if (record2) this.observerNotes.set(key, record2);
115537
+ const wait = own?.run.wait ?? d2.wait;
115538
+ const releaseHold = wait.holdInput();
114406
115539
  const release = () => {
114407
115540
  releaseHold();
114408
115541
  this.applyDeferredRestart(child.id);
@@ -114410,12 +115543,24 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114410
115543
  try {
114411
115544
  await this.deliver(child.id, text2, void 0, from, false, void 0, "inline", (h, input) => {
114412
115545
  const fail4 = (e) => {
114413
- d2.wait.fail(e);
115546
+ if (!record2) {
115547
+ wait.fail(e);
115548
+ return release();
115549
+ }
115550
+ wait.fail(new Error(`your note was not delivered: ${e.message}`));
114414
115551
  release();
115552
+ if (!record2.told && !record2.waiters && this.observerNotes.get(key) === record2) {
115553
+ record2.told = true;
115554
+ void this.tellNoteRefused(callerId, child.id, e.message);
115555
+ }
114415
115556
  };
114416
115557
  void h.sendTracked(input, { complete: release, fail: fail4 }).then(release, fail4);
114417
115558
  });
114418
115559
  } catch (e) {
115560
+ if (record2) {
115561
+ wait.fail(e);
115562
+ if (this.observerNotes.get(key) === record2) this.observerNotes.delete(key);
115563
+ }
114419
115564
  release();
114420
115565
  return { ok: false, error: `Could not send it: ${e.message}` };
114421
115566
  }
@@ -114446,6 +115591,44 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114446
115591
  releaseObservation?.();
114447
115592
  }
114448
115593
  }
115594
+ /**
115595
+ * agent_session on a start_agent request's handle (#673). While the card stands: status says so, wait waits for the
115596
+ * person's answer up to the bound, stop (or delete) withdraws the card, send is refused. Once the child started, the
115597
+ * call goes on to that session (a wait that saw it start answers its status, so the call stays inside its bound);
115598
+ * once it ended without one, the reason.
115599
+ */
115600
+ async agentStartSession(rec, action, signal) {
115601
+ const id2 = rec.req.requestId;
115602
+ if (!rec.ended) {
115603
+ if (action === "stop" || action === "delete") {
115604
+ this.withdrawAgentStart(id2, "The session that asked withdrew the request.", agentStartPersonText(rec.req, "stopped"));
115605
+ return { ok: true, state: "stopped", sessionId: id2, sessionName: "", tool: rec.req.detail.tool };
115606
+ }
115607
+ if (action === "send") return { ok: false, error: `No session yet: request ${id2} is still waiting for the person's answer. Send once it has started.` };
115608
+ if (action === "wait") {
115609
+ let timer;
115610
+ let onAbort;
115611
+ await Promise.race([
115612
+ rec.done,
115613
+ new Promise((resolve12) => timer = setTimeout(resolve12, ASK_WAIT_MS)),
115614
+ new Promise((resolve12) => {
115615
+ if (!signal) return;
115616
+ onAbort = resolve12;
115617
+ if (signal.aborted) resolve12();
115618
+ else signal.addEventListener("abort", onAbort, { once: true });
115619
+ })
115620
+ ]);
115621
+ clearTimeout(timer);
115622
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
115623
+ const ended2 = rec.ended;
115624
+ if (ended2 && "childId" in ended2) return { childId: ended2.childId, action: "status" };
115625
+ }
115626
+ if (!rec.ended) return agentStartPending(rec.req);
115627
+ }
115628
+ const ended = rec.ended;
115629
+ if ("error" in ended) return { ok: false, error: `Request ${id2} started no session: ${ended.error}` };
115630
+ return { childId: ended.childId, action };
115631
+ }
114449
115632
  /**
114450
115633
  * The branch a child's new worktree goes on (#561 part 2): the one asked for, checked by git's own rule, or one
114451
115634
  * named from the job (`agentBranch`). Refused before the card when the project is no git repository, so the person
@@ -114458,8 +115641,22 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114458
115641
  if (asked) return await gitValidBranchName(asked) ? { branch: asked } : { error: `Not a valid branch name: ${asked}` };
114459
115642
  return { branch: agentBranch(opts.task, randomUUID10()) };
114460
115643
  }
114461
- /** Prevent a send/resume from racing the snapshot and worktree removal. */
114462
- worktreeCleanup = /* @__PURE__ */ new Set();
115644
+ /** Prevent a send/resume from racing the snapshot and worktree removal: the child, by id, and its worktree folder */
115645
+ worktreeCleanup = /* @__PURE__ */ new Map();
115646
+ /**
115647
+ * Whether a cleanup under way holds this session still (#647): the child whose worktree goes, and every session whose
115648
+ * folder is inside that worktree. A helper the child started works there; woken during the snapshot, it would write
115649
+ * into a folder about to be removed. Cheap while no cleanup runs, which is nearly always.
115650
+ */
115651
+ worktreeCleanupBlocks(sessionId) {
115652
+ if (this.worktreeCleanup.size === 0) return false;
115653
+ if (this.worktreeCleanup.has(sessionId)) return true;
115654
+ const cwd = this.store.sessionCwd(sessionId) ?? this.meta.get(sessionId)?.worktree?.path;
115655
+ if (!cwd) return false;
115656
+ const path2 = canonicalPath(cwd);
115657
+ for (const folder of this.worktreeCleanup.values()) if (insideFolder(path2, folder)) return true;
115658
+ return false;
115659
+ }
114463
115660
  /** #608: the parent decides; local work follows the session into the trash in every preset. */
114464
115661
  async cleanUpWorktreeChild(target) {
114465
115662
  if (!target.worktree?.base || !target.projectId) {
@@ -114468,10 +115665,21 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114468
115665
  if (target.state === "working" || target.state === "waiting_approval" || this.agentRuns.has(target.id) || sessionBusy({ ...target, live: this.handles.has(target.id) })) {
114469
115666
  return { ok: false, error: `"${target.name}" is still working \u2014 stop it or wait first` };
114470
115667
  }
115668
+ if (!existsSync19(target.worktree.path)) {
115669
+ return { ok: false, error: `The worktree folder for "${target.name}" is missing: ${target.worktree.path}. Restore the folder before cleanup; its branch has been left in place.` };
115670
+ }
114471
115671
  if (this.resuming.has(target.id) || this.deferredRestarts.has(target.id)) return { ok: false, error: `"${target.name}" is waking or restarting \u2014 wait for it first` };
114472
115672
  if (this.worktreeCleanup.has(target.id)) return { ok: false, error: "This worktree is already being removed" };
114473
- this.worktreeCleanup.add(target.id);
115673
+ if (this.trashing.has(target.id)) return { ok: false, error: `"${target.name}" is already being deleted; its worktree stays in place` };
115674
+ const occupied = () => {
115675
+ const occupants = this.sessionsInside(target);
115676
+ if (!occupants.length) return null;
115677
+ return { ok: false, error: `${occupants.map((s) => `"${s.name}"`).join(", ")} ${occupants.length === 1 ? "is" : "are"} still in the worktree of "${target.name}" \u2014 delete ${occupants.length === 1 ? "it" : "them"} first` };
115678
+ };
115679
+ const taken = occupied();
115680
+ if (taken) return taken;
114474
115681
  const { branch, path: path2 } = target.worktree;
115682
+ this.worktreeCleanup.set(target.id, canonicalPath(path2));
114475
115683
  try {
114476
115684
  const cwd = this.cwdOf(target.projectId);
114477
115685
  const handle = this.handles.get(target.id);
@@ -114492,7 +115700,11 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114492
115700
  text: `Local work saved before worktree deletion: ${setAside.changedFiles} uncommitted files, ${setAside.commits} commits not on the trunk. Recovery patches and bundles are at ${worktreeRecoveryDir(target.id)}. Restore from Settings \u2192 Trash or recover these files manually before deleting this session for good.`
114493
115701
  });
114494
115702
  }
115703
+ const appeared = occupied();
115704
+ if (appeared) return appeared;
114495
115705
  await this.trashSession(target.id, false, false, setAside);
115706
+ const appearedSince = occupied();
115707
+ if (appearedSince) return { ok: true, branch, setAside, warning: `The session is in the trash, but worktree ${path2} and branch ${branch} were left: ${appearedSince.error}. Recovery files are at ${worktreeRecoveryDir(target.id)}.` };
114496
115708
  try {
114497
115709
  await gitWorktreeRemove(cwd, path2, true, true);
114498
115710
  } catch (e) {
@@ -114500,6 +115712,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114500
115712
  }
114501
115713
  try {
114502
115714
  await gitBranchDelete(cwd, branch, this.gitTrust(target.projectId));
115715
+ await clearRecoveredBranchMark(cwd, branch).catch(() => {
115716
+ });
114503
115717
  } catch (e) {
114504
115718
  return { ok: true, branch, setAside, warning: `The session is in the trash and its worktree was removed, but branch ${branch} was left: ${e.message}. Recovery files are at ${worktreeRecoveryDir(target.id)}.` };
114505
115719
  }
@@ -114510,12 +115724,27 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114510
115724
  this.worktreeCleanup.delete(target.id);
114511
115725
  }
114512
115726
  }
115727
+ /**
115728
+ * The other sessions whose folder is inside this worktree (#647): a helper the child started without a worktree of
115729
+ * its own works in the child's. One asleep counts too — after a host swap no session has a process, and a message
115730
+ * wakes it right there. Removing the worktree under it would lose what it writes next, so cleanup is refused.
115731
+ */
115732
+ sessionsInside(target) {
115733
+ const root3 = target.worktree?.path;
115734
+ if (!root3) return [];
115735
+ const folder = canonicalPath(root3);
115736
+ return [...this.meta.values()].filter((s) => {
115737
+ if (s.id === target.id) return false;
115738
+ const cwd = this.store.sessionCwd(s.id) ?? s.worktree?.path;
115739
+ return !!cwd && insideFolder(canonicalPath(cwd), folder);
115740
+ });
115741
+ }
114513
115742
  /** A saved ceiling overrides the caller's preset default: auto unlimited, otherwise AGENT_CEILING. */
114514
115743
  agentCeilingError(callerId, projectId) {
114515
115744
  const configured = this.uiPreferences().agentCeiling;
114516
115745
  const ceiling = configured ?? (this.autoBoundaryOpen(callerId) ? 0 : AGENT_CEILING);
114517
115746
  if (ceiling === 0) return null;
114518
- const running = [...this.delegations.values()].filter((d2) => d2.agent && !d2.settled && this.meta.get(d2.sessionId)?.projectId === projectId).length;
115747
+ const running = [...this.delegations.values()].filter((d2) => d2.agent && !d2.settled && this.meta.get(d2.sessionId)?.projectId === projectId).length + (this.agentStartReserved.get(projectId) ?? 0);
114519
115748
  if (running < ceiling) return null;
114520
115749
  return `${running} agents started by this project's sessions are already working, the most at once. Wait for one (agent_session, action "wait") or stop one, then try again.`;
114521
115750
  }
@@ -114525,22 +115754,38 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114525
115754
  * list's ids or labels contain (an alias such as "sonnet") passes, and a tool that cannot list its models passes all.
114526
115755
  */
114527
115756
  async agentChoiceError(tool, askedModel, askedEffort, model) {
114528
- if (!askedModel && !askedEffort) return null;
114529
- const { supported, models } = await this.listModels(tool);
114530
- if (!supported || models.length === 0) return null;
114531
- const find = (name) => {
114532
- const n = name.toLowerCase();
114533
- return models.find((m) => m.id.toLowerCase() === n) ?? models.find((m) => m.id.toLowerCase().includes(n) || m.label.toLowerCase().includes(n));
114534
- };
114535
- if (askedModel && !find(askedModel)) return `${tool} has no model "${askedModel}". Its models: ${models.map((m) => m.id).join(", ")}.`;
114536
- if (askedEffort) {
114537
- const m = model ? find(model) : models[0];
114538
- if (m && m.efforts.length > 0 && !m.efforts.includes(askedEffort)) return `${m.id} has no effort "${askedEffort}". Its efforts: ${m.efforts.join(", ")}.`;
115757
+ if (!askedModel && !askedEffort) return { error: null };
115758
+ const adapter = this.adapters.get(tool);
115759
+ if (!adapter?.listModels) return { error: null };
115760
+ const what = [askedModel && `model "${askedModel}"`, askedEffort && `effort "${askedEffort}"`].filter(Boolean).join(" and ");
115761
+ const notChecked = (why) => ({ error: null, unchecked: `${what} not checked: ${why}. If the session's first turn fails on it, start again without it` });
115762
+ const mismatch = (models) => {
115763
+ const find = (name) => {
115764
+ const n = name.toLowerCase();
115765
+ return models.find((m) => m.id.toLowerCase() === n) ?? models.find((m) => m.id.toLowerCase().includes(n) || m.label.toLowerCase().includes(n));
115766
+ };
115767
+ if (askedModel && !find(askedModel)) return `${tool} has no model "${askedModel}". Its models: ${models.map((m) => m.id).join(", ")}.`;
115768
+ if (askedEffort) {
115769
+ const m = model ? find(model) : models[0];
115770
+ if (m && m.efforts.length > 0 && !m.efforts.includes(askedEffort)) return `${m.id} has no effort "${askedEffort}". Its efforts: ${m.efforts.join(", ")}.`;
115771
+ }
115772
+ return null;
115773
+ };
115774
+ let got = await this.modelsWithin(tool, START_AGENT_MODEL_CHECK_MS);
115775
+ if (!got) return notChecked(`${tool}'s model list was not available in time`);
115776
+ if (got.models.length === 0) return notChecked(`${tool} listed no models`);
115777
+ let wrong = mismatch(got.models);
115778
+ if (wrong && got.cached) {
115779
+ got = await this.modelsWithin(tool, START_AGENT_MODEL_CHECK_MS, true);
115780
+ if (!got) return notChecked(`${tool}'s saved model list lacks it and a new one was not available in time`);
115781
+ if (got.models.length === 0) return notChecked(`${tool} listed no models`);
115782
+ wrong = mismatch(got.models);
114539
115783
  }
114540
- return null;
115784
+ return { error: wrong };
114541
115785
  }
114542
115786
  /** Update grants last only for the calling session on this host; they never change automatic application. */
114543
- async updateCentraluAsked(sessionId, action) {
115787
+ async updateCentraluAsked(sessionId, action, signal) {
115788
+ if (action === "apply") return this.applyUpdateAsked(sessionId, signal);
114544
115789
  const updates2 = this.updates;
114545
115790
  if (!updates2) return { ok: false, error: "Update checks are unavailable" };
114546
115791
  if (this.toolProfileOf(sessionId) === "reader" && !this.updateConsents.has(sessionId)) {
@@ -114548,19 +115793,71 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114548
115793
  if (decision === "always") this.updateConsents.add(sessionId);
114549
115794
  else if (decision !== "allow") return { ok: false, error: "The person did not allow this Centralu update. Do not ask again unless they tell you to." };
114550
115795
  }
114551
- return { ok: true, status: action === "check" ? await updates2.check(true) : updates2.apply() };
115796
+ return { ok: true, status: action === "check" ? await updates2.check(true) : await updates2.apply() };
115797
+ }
115798
+ /**
115799
+ * `update_centralu` action "apply" (#665): a window relaunches into the installed build, as its "Apply now" does.
115800
+ *
115801
+ * By the caller's own preset, read now (as every boundary of #606): `auto` applies at once, `normal` raises a card in
115802
+ * the caller (after a window said it is ready, so "nothing installed" never asks), `safe` is refused, as is an agent an
115803
+ * app started. "Always" on the card covers applying for this session only, apart from check and install. The window
115804
+ * still makes every check its button makes (update-apply.ts), so an agent can only ask for what the person could click.
115805
+ */
115806
+ async applyUpdateAsked(sessionId, signal) {
115807
+ const broker = this.updateApply;
115808
+ if (!broker) return { ok: false, error: "Applying updates is unavailable on this host" };
115809
+ const m = this.meta.get(sessionId);
115810
+ if (!m) return { ok: false, error: "The calling session is gone" };
115811
+ if (this.isAppAgentSession(m)) return { ok: false, error: "An agent an app started cannot apply Centralu updates." };
115812
+ if (m.permissionPreset === "safe") {
115813
+ return {
115814
+ ok: false,
115815
+ error: "This session's preset is safe, which cannot relaunch Centralu. Tell the person the update is ready to apply from the update line; do not ask again unless they tell you to."
115816
+ };
115817
+ }
115818
+ const outcome = await broker.apply(async () => {
115819
+ if (this.updateApplyConsents.has(sessionId)) return null;
115820
+ const decision = await this.askHost(sessionId, { kind: "other", raw: CENTRALU_APPLY_QUESTION, about: CENTRALU_APPLY }, signal);
115821
+ if (decision === "always") this.updateApplyConsents.add(sessionId);
115822
+ else if (decision === null) return "The question to the person was withdrawn before they answered, so nothing was relaunched.";
115823
+ else if (decision !== "allow") return "The person did not allow this session to apply the Centralu update. Do not ask again unless they tell you to.";
115824
+ return null;
115825
+ });
115826
+ if (!outcome.ok) return { ok: false, error: outcome.error };
115827
+ console.error(`[updates] session ${sessionId} applied the installed update: a window is relaunching${outcome.version ? ` into ${outcome.version}` : ""}`);
115828
+ return { ok: true, relaunched: outcome.version ? { version: outcome.version } : {} };
114552
115829
  }
114553
115830
  /**
114554
115831
  * The person's consent for a session to start agents (#561), the first time it does: a card in the caller's approval
114555
115832
  * slot. "Always" is kept for this session while this host serves it; with ⌥ (scope `project`) it is remembered as the
114556
115833
  * project's 'agents' consent, listed in Settings → Permissions with ask_project's pairs and revoked there.
115834
+ *
115835
+ * **Trust is the consent (#673).** A `normal` caller in a trusted project starts agents without a card: the person
115836
+ * already decided about that project when they trusted it (#655), and a card in a session they were not looking at
115837
+ * only made the session work alone. `safe` keeps the card, as it keeps every boundary closed (#606); `auto` opens it.
115838
+ * `onCard` hears the request id before a card is raised, so the request can be kept until the person answers.
114557
115839
  */
114558
- async ensureAgentConsent(callerId, detail, signal) {
115840
+ async ensureAgentConsent(callerId, detail, signal, onCard) {
114559
115841
  if (this.agentConsents.has(callerId)) return { ok: true };
114560
115842
  if (this.store.getProjectConsent(detail.project.id, detail.project.id, "agents")) return { ok: true };
114561
- const decision = await this.askHost(callerId, detail, signal);
115843
+ if (this.trustIsAgentConsent(callerId, detail.project.id)) return { ok: true };
115844
+ const requestId = `ag-${randomUUID10()}`;
115845
+ if (this.meta.get(callerId)?.permissionPreset !== "auto") onCard?.(requestId);
115846
+ return this.agentConsentFrom(callerId, detail.project.id, await this.askHost(callerId, detail, signal, { requestId }));
115847
+ }
115848
+ /** Depth one (#561): a session started by another, or handling an ask_project task now, starts no agents */
115849
+ agentDepthBlocked(caller) {
115850
+ return !!caller.askedBy || [...this.delegations.values()].some((d2) => d2.sessionId === caller.id && !d2.agent && !d2.settled);
115851
+ }
115852
+ /** A `normal` caller in a trusted project needs no card to start agents (#673): the project's trust, read now */
115853
+ trustIsAgentConsent(callerId, projectId) {
115854
+ if (this.meta.get(callerId)?.permissionPreset !== "normal") return false;
115855
+ return this.store.listProjects().find((p) => p.id === projectId)?.trusted === true;
115856
+ }
115857
+ /** The person's answer on a start_agent card, as consent (remembered where they chose "always") or a refusal */
115858
+ agentConsentFrom(callerId, projectId, decision) {
114562
115859
  if (decision === "always_project") {
114563
- this.store.setProjectConsent(detail.project.id, detail.project.id, "agents");
115860
+ this.store.setProjectConsent(projectId, projectId, "agents");
114564
115861
  this.emit({ type: "project_consents_changed" });
114565
115862
  return { ok: true };
114566
115863
  }
@@ -114569,15 +115866,112 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114569
115866
  return { ok: true };
114570
115867
  }
114571
115868
  if (decision === "allow") return { ok: true };
114572
- if (decision === "deny") return { ok: false, error: "The person did not allow this session to start another agent. Do not start one again unless they tell you to." };
114573
- return { ok: false, error: "The question to the person was withdrawn before they answered, so no agent was started." };
115869
+ if (decision === "deny") {
115870
+ return { ok: false, why: "denied", error: `The person did not allow this session to start another agent. Do not start one again unless they tell you to. ${ASK_THE_PERSON}` };
115871
+ }
115872
+ return { ok: false, why: "withdrawn", error: `The question to the person was withdrawn before they answered, so no agent was started. ${ASK_THE_PERSON}` };
115873
+ }
115874
+ /** A start_agent request whose card now stands (#673): kept, in memory and in the store, until it is answered */
115875
+ openAgentStart(req) {
115876
+ let end;
115877
+ const done = new Promise((resolve12) => end = resolve12);
115878
+ const rec = { req, detached: false, done, end };
115879
+ this.agentStarts.set(req.requestId, rec);
115880
+ this.store.putAgentStartRequest(req.requestId, req.callerId, req);
115881
+ return rec;
115882
+ }
115883
+ /**
115884
+ * How a start_agent request ended (#673), once: the row goes, a wait on its handle returns, and unless a session
115885
+ * started, the person is shown why in the caller's session (not only the model, in a tool result). A request whose
115886
+ * call already answered "pending" is told to the caller as a message, since no call is left to answer it.
115887
+ * A refusal the person gave on the card while the call waited needs no line: the card itself says "Denied".
115888
+ */
115889
+ endAgentStart(requestId, ended) {
115890
+ const rec = requestId ? this.agentStarts.get(requestId) : void 0;
115891
+ if (!rec || rec.ended) return;
115892
+ rec.ended = "childId" in ended ? { childId: ended.childId } : { error: ended.error };
115893
+ rec.end();
115894
+ this.store.deleteAgentStartRequest(rec.req.requestId);
115895
+ if ("childId" in ended) return;
115896
+ const { callerId } = rec.req;
115897
+ if (!this.meta.has(callerId)) return;
115898
+ if (!ended.denied || rec.detached) {
115899
+ this.onEvent({ type: "notice", sessionId: callerId, level: "warning", from: "Centralu", label: "agent request", audience: "you", text: ended.person });
115900
+ }
115901
+ if (rec.detached) void this.tellAgentStartEnded(rec.req, ended.error);
115902
+ }
115903
+ /** The caller of a request that already answered "pending" hears how it ended (#673), as a message in its session */
115904
+ async tellAgentStartEnded(req, why) {
115905
+ if (this.handingOver || this.disposing || !this.meta.has(req.callerId)) return;
115906
+ try {
115907
+ await this.deliver(
115908
+ req.callerId,
115909
+ `[Centralu] Your start_agent request ${req.requestId} did not start a session.
115910
+ Agent: ${frameField(req.detail.tool)}
115911
+ Task: ${frameField(req.detail.text)}
115912
+
115913
+ Reason: ${why.slice(0, 600)}`,
115914
+ void 0,
115915
+ { sessionId: HOST_SENDER, name: "Centralu" },
115916
+ false
115917
+ );
115918
+ } catch {
115919
+ }
115920
+ }
115921
+ /**
115922
+ * Withdraws a request's card, when the session that asked withdraws it or is deleted (#673): the card closes, the
115923
+ * request ends with `person` shown, and whatever waits on the card hears no answer.
115924
+ */
115925
+ withdrawAgentStart(requestId, error51, person) {
115926
+ const rec = this.agentStarts.get(requestId);
115927
+ if (!rec || rec.ended) return;
115928
+ this.endAgentStart(requestId, { error: error51, person });
115929
+ const ask = this.hostAsks.get(requestId);
115930
+ if (!ask) return;
115931
+ this.hostAsks.delete(requestId);
115932
+ if (this.meta.get(ask.sessionId)?.pendingApproval?.requestId === requestId) {
115933
+ this.restAfterCard(ask);
115934
+ this.onEvent({ type: "approval_resolved", sessionId: ask.sessionId, requestId, decision: "deny" });
115935
+ }
115936
+ ask.resolve(null);
115937
+ }
115938
+ /**
115939
+ * A request the previous host left standing (#673, `agent_start_requests`): its card is raised again, and an
115940
+ * approval starts the child here, with the report armed, as the call would have. A request whose caller is gone ends.
115941
+ */
115942
+ resumeAgentStart(req) {
115943
+ const caller = this.meta.get(req.callerId);
115944
+ if (!caller) {
115945
+ this.store.deleteAgentStartRequest(req.requestId);
115946
+ return;
115947
+ }
115948
+ const rec = this.openAgentStart(req);
115949
+ rec.detached = true;
115950
+ void (async () => {
115951
+ const decision = await this.askHost(req.callerId, req.detail, void 0, { requestId: req.requestId, shown: true });
115952
+ if (decision === null && (this.handingOver || this.disposing)) return;
115953
+ const consent = this.agentConsentFrom(req.callerId, req.projectId, decision);
115954
+ if (!consent.ok) {
115955
+ this.endAgentStart(req.requestId, { error: consent.error, person: agentStartPersonText(req, consent.why === "denied" ? "denied" : "withdrawn"), denied: consent.why === "denied" });
115956
+ return;
115957
+ }
115958
+ const project2 = this.store.listProjects().find((p) => p.id === req.projectId);
115959
+ const r = project2 ? await this.createAgentChild(req.callerId, { id: project2.id, name: project2.name, path: project2.path }, req, { stage: "creating" }, req.detail.preset) : { done: { ok: false, error: "This session's project is no longer registered." } };
115960
+ if ("key" in r) {
115961
+ this.endAgentStart(req.requestId, { childId: r.d.sessionId });
115962
+ this.agentWorking(r.d);
115963
+ } else {
115964
+ const error51 = r.done.ok ? "No session was created." : r.done.error;
115965
+ this.endAgentStart(req.requestId, { error: error51, person: agentStartPersonText(req, "failed", error51) });
115966
+ }
115967
+ })();
114574
115968
  }
114575
115969
  /**
114576
115970
  * "Working": the caller is told when the turn ends (#561), through the report the orchestrator's reportBack uses, unless
114577
115971
  * a call is waiting on it by then (`awaitAgent` takes the marker down while it waits)
114578
115972
  */
114579
115973
  agentWorking(d2, arm = true, observing = false) {
114580
- if (arm && !observing) this.awaitingReport.set(d2.sessionId, d2.callerId);
115974
+ if (arm && !observing) this.markReport(d2.sessionId, d2.callerId);
114581
115975
  const notice = d2.notice();
114582
115976
  return {
114583
115977
  ok: true,
@@ -114589,6 +115983,43 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114589
115983
  ...notice ? { notice } : {}
114590
115984
  };
114591
115985
  }
115986
+ /** An observer's `status` / `wait` on the note it sent (#646): its own reader's outcome, taken once it has settled */
115987
+ async readObserverNote(key, note, wait, signal) {
115988
+ if (!wait && !note.run.settled) return this.agentWorking(note.run, false, true);
115989
+ note.waiters++;
115990
+ let r;
115991
+ try {
115992
+ r = await this.awaitAgent(key, note.run, signal, true);
115993
+ } finally {
115994
+ note.waiters--;
115995
+ }
115996
+ if (note.run.settled) {
115997
+ note.told = true;
115998
+ if (this.observerNotes.get(key) === note) this.observerNotes.delete(key);
115999
+ }
116000
+ return r;
116001
+ }
116002
+ /** The observer's note was refused after its send returned (#646): said in the observer's session, as a report is */
116003
+ async tellNoteRefused(observerId, sessionId, why) {
116004
+ if (this.handingOver || !this.meta.has(observerId)) return;
116005
+ const name = this.meta.get(sessionId)?.name ?? sessionId;
116006
+ try {
116007
+ await this.deliver(
116008
+ observerId,
116009
+ `[Centralu] Your note to another session was not delivered.
116010
+ Session: ${frameField(name)}
116011
+ id: ${sessionId}
116012
+
116013
+ Reason: ${why.slice(0, 600)}
116014
+
116015
+ Send it again with agent_session, or read that session's recent conversation with read_session.`,
116016
+ void 0,
116017
+ { sessionId, name },
116018
+ true
116019
+ );
116020
+ } catch {
116021
+ }
116022
+ }
114592
116023
  /** A child with nothing of the caller's running: its last final answer (#561) */
114593
116024
  agentAtRest(child) {
114594
116025
  return {
@@ -114602,12 +116033,12 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114602
116033
  };
114603
116034
  }
114604
116035
  /** Waits on a child's turn up to the bound (#561), like `awaitDelegation`, and reads its outcome for the caller */
114605
- async awaitAgent(key, d2, signal, observing = false) {
114606
- if (!observing && this.awaitingReport.get(d2.sessionId) === d2.callerId) this.awaitingReport.delete(d2.sessionId);
116036
+ async awaitAgent(key, d2, signal, observing = false, boundMs = ASK_WAIT_MS) {
116037
+ if (!observing && this.awaitingReport.get(d2.sessionId) === d2.callerId) this.clearReport(d2.sessionId);
114607
116038
  const name = () => this.meta.get(d2.sessionId)?.name ?? d2.sessionId;
114608
116039
  let timer;
114609
116040
  let onAbort;
114610
- const bound = new Promise((resolve12) => timer = setTimeout(() => resolve12("bound"), ASK_WAIT_MS));
116041
+ const bound = new Promise((resolve12) => timer = setTimeout(() => resolve12("bound"), boundMs));
114611
116042
  const aborted2 = new Promise((resolve12) => {
114612
116043
  if (!signal) return;
114613
116044
  onAbort = () => resolve12("aborted");
@@ -114710,6 +116141,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114710
116141
  const next = [...this.hostAsks.values()].find((a) => a.sessionId === sessionId);
114711
116142
  if (!next) return;
114712
116143
  const e = { type: "approval_request", sessionId, requestId: next.requestId, detail: next.detail };
116144
+ next.restState = next.shown && !inTurn(m.state) ? m.state : void 0;
114713
116145
  if (!next.shown) {
114714
116146
  next.shown = true;
114715
116147
  this.onEvent(e);
@@ -114719,6 +116151,19 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114719
116151
  this.store.upsertSession(m);
114720
116152
  this.emit(e);
114721
116153
  }
116154
+ /**
116155
+ * A host card raised between turns closes back into that rest (#673), not into "working": no tool call is waiting on
116156
+ * it, so nothing would ever end that turn. Set before the card's `approval_resolved`, which then changes no state, and
116157
+ * without `onEvent`, since no turn ended (no report, no deferred restart).
116158
+ */
116159
+ restAfterCard(ask) {
116160
+ const m = this.meta.get(ask.sessionId);
116161
+ if (!ask.restState || m?.pendingApproval?.requestId !== ask.requestId) return;
116162
+ const e = { type: "state_change", sessionId: ask.sessionId, state: ask.restState };
116163
+ this.applyStateHint(e, m);
116164
+ this.store.upsertSession(m);
116165
+ this.emit(e);
116166
+ }
114722
116167
  /** Screen-originated capability questions still awaiting an answer (M4 D-4) */
114723
116168
  appQuestionList() {
114724
116169
  return [...this.appQuestions.values()].map((x) => ({ ...x.question })).sort((a, b2) => a.askedAt - b2.askedAt);
@@ -114961,8 +116406,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114961
116406
  if (!profileAllows(profile, name)) {
114962
116407
  throw Object.assign(new Error(`Not a tool of this session: ${name}`), { code: "internal" });
114963
116408
  }
114964
- const tools = profile === "scoped" ? this.orchestratorToolsFor(sessionId, m.scopeSessionIds ?? []) : profile === "reader" ? this.readerToolsFor(sessionId) : this.orchestratorToolsFor(sessionId);
114965
- return runOrchestratorTool(tools, name, args, { sessionId, profile });
116409
+ return runOrchestratorTool(this.toolBundleOf(sessionId, profile, m), name, args, { sessionId, profile });
114966
116410
  }
114967
116411
  /**
114968
116412
  * Receives the external app runtime (M4 A-5) — a session that comes up after this receives whatever
@@ -114980,6 +116424,10 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114980
116424
  useUpdates(updates2) {
114981
116425
  this.updates = updates2;
114982
116426
  }
116427
+ /** The windows' "Apply now" for an agent (#665, update-apply.ts) — wired by the host, which owns the event stream */
116428
+ useUpdateApply(broker) {
116429
+ this.updateApply = broker;
116430
+ }
114983
116431
  /**
114984
116432
  * Says in the conversation that the session moved to a newer CLI (#297): restarting a process by itself is never
114985
116433
  * silent. A notice from Centralu, not from the tool, so it reads as what happened rather than as something to fix.
@@ -115124,41 +116572,33 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115124
116572
  if (this.isAppAgentSession(m)) return void 0;
115125
116573
  return this.appsHub?.attach({ id: m.id, kind: m.kind, projectId: m.projectId, builderOf: this.builderRefOf(m) }) ?? void 0;
115126
116574
  }
116575
+ /** Is this an agent session stood up at an external app's request (M4 D-1; `isAppAgent` in tool-profiles.ts) */
116576
+ isAppAgentSession(m) {
116577
+ return isAppAgent({ kind: m.kind, appId: m.appId ?? null, builder: this.builderRefOf(m) !== null });
116578
+ }
115127
116579
  /**
115128
- * Is this an agent session stood up at an external app's request (M4 D-1) — one of the workers an
115129
- * external app owns (`appId`) that is not that app's own builder session. No separate marker exists
115130
- * for this: ownership (`appId`) and the builder directory already say it. A row stamped with a
115131
- * reserved id belongs to the removed control app (#372), never to an external app.
116580
+ * What the profile rule (tool-profiles.ts) reads about this session: its row, whether its app's builder
116581
+ * slot points at it (looked up unless the caller just wrote it), and the Settings switch (#320) as it is
116582
+ * right now.
115132
116583
  */
115133
- isAppAgentSession(m) {
115134
- return !!m.appId && m.kind === "worker" && !RESERVED_APP_IDS.includes(m.appId) && this.builderRefOf(m) === null;
116584
+ profileFacts(m, builder = this.builderRefOf(m) !== null) {
116585
+ return { kind: m.kind, projectId: m.projectId, appId: m.appId ?? null, builder, sessionTools: this.uiPreferences().sessionTools };
115135
116586
  }
115136
116587
  /**
115137
- * The tool bundle this session receives (#69). null means no tools.
115138
- * Whether something is a manager comes down to a single relationship: if it has a worktree child, it
115139
- * is a manager (including an archived child — tools are not dangerous, and a manager created through
115140
- * adoption must still be able to propose things after its children are cleaned up).
116588
+ * The tool bundle this session receives (#69). null means no tools. The rule is `toolProfileFor`
116589
+ * (tool-profiles.ts); creation and every wake hand the adapter the same answer, and the bridge asks
116590
+ * here on every call.
115141
116591
  */
115142
116592
  toolProfileOf(sessionId) {
115143
116593
  const m = this.meta.get(sessionId);
115144
- if (!m) return null;
115145
- if (m.kind === "orchestrator") return "orchestrator";
115146
- if (m.kind === "coordinator") return "scoped";
115147
- if (this.builderRefOf(m)) return "builder";
115148
- return this.readsOwnProject(m) ? "reader" : null;
116594
+ return m ? toolProfileFor(this.profileFacts(m)) : null;
115149
116595
  }
115150
- /**
115151
- * Does this ordinary session get the light, read-only set (#320)? On unless the person turned it off
115152
- * in Settings, and never for an agent session an app stood up (M4 D-1): that agent hands its answer
115153
- * back to the app, so reading the project's conversations would carry them out to an app that was
115154
- * never given them (the same reason it gets no apps, appsFor).
115155
- *
115156
- * The schemas are attached when the session starts, like every other bundle; turning the setting off
115157
- * stops the calls at once (readerToolsFor and the bridge both ask again) and takes the schemas away
115158
- * the next time the session starts.
115159
- */
115160
- readsOwnProject(m) {
115161
- return m.kind === "worker" && !!m.projectId && !this.isAppAgentSession(m) && this.uiPreferences().sessionTools;
116596
+ toolBundleOf(sessionId, profile, m) {
116597
+ return toolBundleFor(profile, m.scopeSessionIds, {
116598
+ orchestrator: () => this.orchestratorToolsFor(sessionId),
116599
+ scoped: (ids) => this.orchestratorToolsFor(sessionId, ids),
116600
+ reader: () => this.readerToolsFor(sessionId)
116601
+ });
115162
116602
  }
115163
116603
  /**
115164
116604
  * A profile that directs other sessions — every profile but the reader. The reader set (#320)
@@ -115355,8 +116795,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115355
116795
  return readMessageImage(abs);
115356
116796
  }
115357
116797
  repliesName(sessionId, written) {
115358
- const open5 = this.streams.get(sessionId);
115359
- if (open5?.kind === "text" && mentions(open5.text, written)) return true;
116798
+ const open6 = this.streams.get(sessionId);
116799
+ if (open6?.kind === "text" && mentions(open6.text, written)) return true;
115360
116800
  return writtenForms(written).some((form) => this.store.replyTextsContaining(sessionId, form).some((text2) => mentions(text2, written)));
115361
116801
  }
115362
116802
  /**
@@ -115402,6 +116842,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115402
116842
  );
115403
116843
  }
115404
116844
  async disposeAll() {
116845
+ this.disposing = true;
115405
116846
  this.watchers.close();
115406
116847
  for (const readers of this.turnObservers.values()) for (const observer of readers) observer.fail(new Error("Centralu is stopping"));
115407
116848
  this.appsHub?.rt.attachBrokerHost(null);
@@ -115433,6 +116874,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115433
116874
  async detachAll() {
115434
116875
  this.watchers.close();
115435
116876
  this.appsHub?.rt.attachBrokerHost(null);
116877
+ this.handingOver = true;
115436
116878
  for (const run of [...this.agentRuns.values()]) run.fail(new Error("Centralu is restarting"));
115437
116879
  for (const readers of this.turnObservers.values()) for (const observer of readers) observer.fail(new Error("Centralu is restarting"));
115438
116880
  for (const ask of [...this.hostAsks.values()]) ask.resolve(null);
@@ -115466,7 +116908,7 @@ import { relative as relative9 } from "node:path";
115466
116908
  // packages/agent-host/src/themes.ts
115467
116909
  import { randomBytes as randomBytes4 } from "node:crypto";
115468
116910
  import { watch as watch2 } from "node:fs";
115469
- import { mkdir as mkdir7, readdir as readdir9, readFile as readFile5, rename as rename4, stat as stat6, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
116911
+ import { mkdir as mkdir7, readdir as readdir9, readFile as readFile5, rename as rename5, stat as stat6, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
115470
116912
  import { basename as basename6, join as join30 } from "node:path";
115471
116913
  var MAX_FILE_BYTES = 256 * 1024;
115472
116914
  var SETTLE_MS = 100;
@@ -115580,7 +117022,7 @@ var ThemeFiles = class {
115580
117022
  const tmp = `${path2}.${randomBytes4(6).toString("hex")}.tmp`;
115581
117023
  try {
115582
117024
  await writeFile5(tmp, text2, "utf8");
115583
- await rename4(tmp, path2);
117025
+ await rename5(tmp, path2);
115584
117026
  } catch (e) {
115585
117027
  await unlink2(tmp).catch(() => {
115586
117028
  });
@@ -115591,7 +117033,7 @@ var ThemeFiles = class {
115591
117033
 
115592
117034
  // packages/agent-host/src/dev-services/strays.ts
115593
117035
  import { execFile as execFile7 } from "node:child_process";
115594
- import { realpathSync as realpathSync8 } from "node:fs";
117036
+ import { realpathSync as realpathSync9 } from "node:fs";
115595
117037
  import { promisify as promisify7 } from "node:util";
115596
117038
  import { userInfo } from "node:os";
115597
117039
  var exec7 = promisify7(execFile7);
@@ -115702,7 +117144,7 @@ function resolveRoots(roots) {
115702
117144
  for (const r of roots) {
115703
117145
  out.add(r);
115704
117146
  try {
115705
- out.add(realpathSync8(r));
117147
+ out.add(realpathSync9(r));
115706
117148
  } catch {
115707
117149
  }
115708
117150
  }
@@ -115815,7 +117257,7 @@ var toInfo = (h) => ({
115815
117257
  function sessionIdsOf(panels) {
115816
117258
  return panels.flatMap((p) => p.kind === "session" ? [p.sessionId] : []);
115817
117259
  }
115818
- function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: updates2, commands, externalApps: externalApps2, views: views2, inlineViews: inlineViews2, heldPids, themes: themes2, agentVersions: agentVersions2, sleeper: sleeper2, machines, stopHost, activity: activity2 } = {}) {
117260
+ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: updates2, commands, externalApps: externalApps2, views: views2, inlineViews: inlineViews2, heldPids, themes: themes2, agentVersions: agentVersions2, sleeper: sleeper2, machines, stopHost, activity: activity2, updateApply: updateApply2 } = {}) {
115819
117261
  const requireThemes = () => {
115820
117262
  if (!themes2) throw Object.assign(new Error("Theme files are unavailable"), { code: "internal" });
115821
117263
  return themes2;
@@ -115928,8 +117370,8 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
115928
117370
  "agents.resumeSession": async (p) => mgr2.resumeSession(RpcMethods["agents.resumeSession"].params.parse(p).sessionId),
115929
117371
  "agents.forkConversation": async (p) => mgr2.forkConversation(RpcMethods["agents.forkConversation"].params.parse(p).sessionId),
115930
117372
  "agents.updateSettings": async (p) => {
115931
- const { sessionId, ...settings } = RpcMethods["agents.updateSettings"].params.parse(p);
115932
- return await mgr2.updateSettings(sessionId, settings);
117373
+ const { sessionId, by, ...settings } = RpcMethods["agents.updateSettings"].params.parse(p);
117374
+ return await mgr2.updateSettings(sessionId, settings, by ?? "person");
115933
117375
  },
115934
117376
  "agents.switchTool": async (p) => {
115935
117377
  const { sessionId, tool } = RpcMethods["agents.switchTool"].params.parse(p);
@@ -116289,7 +117731,7 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
116289
117731
  },
116290
117732
  "projects.setTrusted": async (p) => {
116291
117733
  const { projectId, trusted } = RpcMethods["projects.setTrusted"].params.parse(p);
116292
- mgr2.setProjectTrusted(projectId, trusted);
117734
+ void mgr2.setProjectTrusted(projectId, trusted).catch((err) => console.error("[sessions] restarting for a trust change failed:", err));
116293
117735
  externalApps2?.refresh();
116294
117736
  return { ok: true };
116295
117737
  },
@@ -116477,6 +117919,16 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
116477
117919
  // Answers once the install has started, not once it has finished — see the note on
116478
117920
  // `updates.apply` in the protocol. The rest arrives as `update_status` events.
116479
117921
  "updates.apply": async () => requireUpdates().apply(),
117922
+ // A window's answer and report for an agent's "Apply now" (#665). Without the broker (tests) nothing is waiting
117923
+ "updates.answerApply": async (p, ctx) => {
117924
+ const a = RpcMethods["updates.answerApply"].params.parse(p);
117925
+ return updateApply2 && ctx ? updateApply2.answer(ctx.client, a) : { ticket: null };
117926
+ },
117927
+ "updates.reportApply": async (p, ctx) => {
117928
+ const r = RpcMethods["updates.reportApply"].params.parse(p);
117929
+ if (updateApply2 && ctx) updateApply2.report(ctx.client, r);
117930
+ return {};
117931
+ },
116480
117932
  "host.stop": async () => {
116481
117933
  if (!stopHost) {
116482
117934
  throw Object.assign(new Error("This host runs under the Centralu app, which stops it: quit the app instead"), { code: "internal" });
@@ -116551,6 +118003,143 @@ function onExternalAppListChanged(apps, emit) {
116551
118003
  });
116552
118004
  }
116553
118005
 
118006
+ // packages/agent-host/src/update-apply.ts
118007
+ import { randomUUID as randomUUID11 } from "node:crypto";
118008
+ var ANSWER_MS = 5e3;
118009
+ var OUTCOME_MS = 2e4;
118010
+ var GONE_GRACE_MS = 5e3;
118011
+ var NO_WINDOW = "No Centralu window is open, so nothing was relaunched. The installed build starts the next time the person opens Centralu.";
118012
+ var WINDOW_GONE = "The window that was told to relaunch went away before it said it was relaunching, so whether it relaunched is not known. Look at Centralu.";
118013
+ var NO_ANSWER = "No Centralu window answered. A window of an older build cannot apply an update for an agent: ask the person to click Apply now on the update line.";
118014
+ var UpdateApplyBroker = class {
118015
+ constructor(opts) {
118016
+ this.opts = opts;
118017
+ }
118018
+ opts;
118019
+ probes = /* @__PURE__ */ new Map();
118020
+ goes = /* @__PURE__ */ new Map();
118021
+ /**
118022
+ * The whole call: ask, then the caller's consent (null to go on, or why not), then ask again and relaunch one window.
118023
+ * `consent` runs only when a window was ready, so "nothing installed" never raises a card.
118024
+ */
118025
+ async apply(consent) {
118026
+ const first = await this.probe();
118027
+ if (!first.ready) return { ok: false, error: first.error };
118028
+ const refused = await consent();
118029
+ if (refused) return { ok: false, error: refused };
118030
+ const ready = await this.probe();
118031
+ if (!ready.ready) return { ok: false, error: ready.error };
118032
+ return this.go(ready);
118033
+ }
118034
+ /** Asks every window whether its "Apply now" could relaunch now; resolves with the first ready one */
118035
+ probe() {
118036
+ if (this.opts.windows() === 0) return Promise.resolve({ ready: false, error: NO_WINDOW });
118037
+ const requestId = randomUUID11();
118038
+ return new Promise((resolve12) => {
118039
+ const p = {
118040
+ requestId,
118041
+ answered: /* @__PURE__ */ new Set(),
118042
+ reasons: [],
118043
+ finish: (r) => {
118044
+ if (!this.probes.delete(requestId)) return;
118045
+ clearTimeout(timer);
118046
+ resolve12(r);
118047
+ }
118048
+ };
118049
+ const timer = setTimeout(() => p.finish(this.notReady(p)), this.opts.answerMs ?? ANSWER_MS);
118050
+ this.probes.set(requestId, p);
118051
+ this.opts.push({ type: "update_apply_request", requestId });
118052
+ });
118053
+ }
118054
+ /** `updates.answerApply`: a ticket for the first ready window of a request still waiting, null for every other */
118055
+ answer(client, a) {
118056
+ const p = this.probes.get(a.requestId);
118057
+ if (!p || p.answered.has(client)) return { ticket: null };
118058
+ p.answered.add(client);
118059
+ if (a.ready) {
118060
+ const ticket = randomUUID11();
118061
+ p.finish({ ready: true, requestId: a.requestId, ticket, client, ...a.version ? { version: a.version } : {} });
118062
+ return { ticket };
118063
+ }
118064
+ const reason = a.reason?.trim() || "That window cannot relaunch into the installed build.";
118065
+ if (!p.reasons.includes(reason)) p.reasons.push(reason);
118066
+ if (p.answered.size >= this.opts.windows()) p.finish(this.notReady(p));
118067
+ return { ticket: null };
118068
+ }
118069
+ /**
118070
+ * Tells the picked window to relaunch and waits for its outcome (#665 review). The window says "started" right before
118071
+ * it relaunches; only after that does its connection ending count as the relaunch. Ending before it — a crash, a
118072
+ * reload, a lost socket — is not a success: the window is given `goneGraceMs` to come back and report under the same
118073
+ * ticket, then the answer says whether it relaunched is not known.
118074
+ */
118075
+ go(ready) {
118076
+ return new Promise((resolve12) => {
118077
+ const g5 = {
118078
+ requestId: ready.requestId,
118079
+ ticket: ready.ticket,
118080
+ client: ready.client,
118081
+ started: false,
118082
+ ...ready.version ? { version: ready.version } : {},
118083
+ finish: (o) => {
118084
+ if (!this.goes.delete(ready.ticket)) return;
118085
+ clearTimeout(timer);
118086
+ clearTimeout(g5.grace);
118087
+ resolve12(o);
118088
+ }
118089
+ };
118090
+ const timer = setTimeout(
118091
+ () => g5.finish({
118092
+ ok: false,
118093
+ error: g5.started ? "The window said it was relaunching but is still open. Look at Centralu to see whether it relaunched." : "The window was told to relaunch but did not say whether it did. Look at Centralu to see whether it relaunched."
118094
+ }),
118095
+ this.opts.outcomeMs ?? OUTCOME_MS
118096
+ );
118097
+ this.goes.set(ready.ticket, g5);
118098
+ this.opts.push({ type: "update_apply_go", requestId: ready.requestId, ticket: ready.ticket });
118099
+ });
118100
+ }
118101
+ /**
118102
+ * `updates.reportApply`: the picked window's outcome. Matched by ticket, not by connection: only that window was given
118103
+ * it, and a window that reconnected has a new connection number. A report naming another ticket, or a settled one, is
118104
+ * dropped. The connection it came on is the one whose end is watched from then on.
118105
+ */
118106
+ report(client, r) {
118107
+ const g5 = this.goes.get(r.ticket);
118108
+ if (!g5 || g5.requestId !== r.requestId) return;
118109
+ g5.client = client;
118110
+ clearTimeout(g5.grace);
118111
+ g5.grace = void 0;
118112
+ if (r.error) return g5.finish({ ok: false, error: r.error });
118113
+ if (r.started) {
118114
+ g5.started = true;
118115
+ return;
118116
+ }
118117
+ g5.finish(this.relaunched(g5));
118118
+ }
118119
+ /** A window's connection ended: after "started" that is the relaunch; before it, unknown; and one less to wait for */
118120
+ clientGone(client) {
118121
+ for (const g5 of [...this.goes.values()]) {
118122
+ if (g5.client !== client) continue;
118123
+ if (g5.started) {
118124
+ g5.finish(this.relaunched(g5));
118125
+ continue;
118126
+ }
118127
+ clearTimeout(g5.grace);
118128
+ g5.grace = setTimeout(() => g5.finish({ ok: false, error: WINDOW_GONE }), this.opts.goneGraceMs ?? GONE_GRACE_MS);
118129
+ }
118130
+ for (const p of [...this.probes.values()]) {
118131
+ if (p.answered.size >= this.opts.windows()) p.finish(this.notReady(p));
118132
+ }
118133
+ }
118134
+ relaunched(g5) {
118135
+ return { ok: true, ...g5.version ? { version: g5.version } : {} };
118136
+ }
118137
+ notReady(p) {
118138
+ if (p.reasons.length === 0) return { ready: false, error: this.opts.windows() === 0 ? NO_WINDOW : NO_ANSWER };
118139
+ return { ready: false, error: `Nothing was relaunched. ${p.reasons.join(" ")}` };
118140
+ }
118141
+ };
118142
+
116554
118143
  // packages/agent-host/src/app-change-events.ts
116555
118144
  var APP_CHANGE_WINDOW_MS = 250;
116556
118145
  function broadcastAppChanges(send, windowMs = APP_CHANGE_WINDOW_MS) {
@@ -116558,9 +118147,9 @@ function broadcastAppChanges(send, windowMs = APP_CHANGE_WINDOW_MS) {
116558
118147
  return {
116559
118148
  emit(ref, cause2 = null) {
116560
118149
  const key = JSON.stringify([ref.projectId, ref.appId]);
116561
- const open5 = pending.get(key);
116562
- if (open5) {
116563
- if (!sameCause(open5.cause, cause2)) open5.cause = null;
118150
+ const open6 = pending.get(key);
118151
+ if (open6) {
118152
+ if (!sameCause(open6.cause, cause2)) open6.cause = null;
116564
118153
  return;
116565
118154
  }
116566
118155
  const timer = setTimeout(() => {
@@ -117020,12 +118609,16 @@ var CommandRunner = class {
117020
118609
  };
117021
118610
 
117022
118611
  // packages/agent-host/src/updates.ts
117023
- import { execFile as execFile8 } from "node:child_process";
117024
- import { existsSync as existsSync21 } from "node:fs";
118612
+ import { execFile as execFile8, execFileSync as execFileSync4 } from "node:child_process";
118613
+ import { existsSync as existsSync21, readFileSync as readFileSync13 } from "node:fs";
118614
+ import { join as join31 } from "node:path";
117025
118615
  var REGISTRY_URL = `https://registry.npmjs.org/${APP_SLUG}/latest`;
117026
118616
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
117027
118617
  var SCHEDULED_TIMEOUT_MS = 3e3;
117028
118618
  var FORCED_TIMEOUT_MS = 8e3;
118619
+ var INSTALL_CHECK_MAX_AGE_MS = 6e4;
118620
+ var STORED_RELEASE_MAX_AGE_MS = 60 * 60 * 1e3;
118621
+ var AUTO_RETRY_DELAYS_MS = [10 * 60 * 1e3, 60 * 60 * 1e3, 6 * 60 * 60 * 1e3];
117029
118622
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
117030
118623
  var NPM_RETRY_DELAY_MS = 3e4;
117031
118624
  var NPM_RETRY_WINDOW_MS = 10 * 60 * 1e3;
@@ -117033,6 +118626,16 @@ function existingInstalledCopy() {
117033
118626
  const path2 = installedCopyPath();
117034
118627
  return existsSync21(path2) ? path2 : null;
117035
118628
  }
118629
+ function readInstalledCopyVersion(copy, platform = HOST_PLATFORM) {
118630
+ const source = installedCopyVersionSource(copy, platform);
118631
+ if (!source) return null;
118632
+ try {
118633
+ const text2 = source.kind === "command" ? execFileSync4(source.file, source.args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3, windowsHide: true }) : readFileSync13(join31(copy, source.name), "utf8");
118634
+ return text2.trim() || null;
118635
+ } catch {
118636
+ return null;
118637
+ }
118638
+ }
117036
118639
  async function fetchLatestFromRegistry(timeoutMs) {
117037
118640
  try {
117038
118641
  const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(timeoutMs) });
@@ -117072,7 +118675,13 @@ var UpdateService = class {
117072
118675
  fetchLatest: deps.fetchLatest ?? fetchLatestFromRegistry,
117073
118676
  run: deps.run ?? runCommand,
117074
118677
  installedCopy: deps.installedCopy ?? existingInstalledCopy,
118678
+ installedCopyVersion: deps.installedCopyVersion ?? ((copy) => readInstalledCopyVersion(copy)),
117075
118679
  now: deps.now ?? Date.now,
118680
+ schedule: deps.schedule ?? ((fn2, ms3) => {
118681
+ const t = setTimeout(fn2, ms3);
118682
+ t.unref?.();
118683
+ return () => clearTimeout(t);
118684
+ }),
117076
118685
  // If no place to persist it is given, treat auto-update as on — the same default that main.ts
117077
118686
  // passes in.
117078
118687
  readAuto: deps.readAuto ?? (() => true),
@@ -117106,10 +118715,16 @@ var UpdateService = class {
117106
118715
  }
117107
118716
  publish;
117108
118717
  status;
118718
+ stopped = false;
118719
+ installing = false;
117109
118720
  timer = null;
117110
118721
  deps;
117111
118722
  /** One check at a time — the timer and the button can land together */
117112
118723
  inFlight = null;
118724
+ /** The last failed install: which version, how many times in a row, and when (#645) */
118725
+ retry = null;
118726
+ /** Cancels the automatic retry timer, while one is pending */
118727
+ retryTimer = null;
117113
118728
  current() {
117114
118729
  return { ...this.status };
117115
118730
  }
@@ -117121,16 +118736,23 @@ var UpdateService = class {
117121
118736
  * closed while releases happened.
117122
118737
  */
117123
118738
  start() {
118739
+ this.stopped = false;
117124
118740
  if (!this.status.auto) return;
117125
118741
  void this.check(false);
117126
118742
  this.startTimer();
117127
118743
  }
118744
+ /** Shutdown (including a host swap): no asynchronous continuation may start more work. */
117128
118745
  stop() {
118746
+ this.stopped = true;
118747
+ this.stopTimer();
118748
+ this.cancelRetry();
118749
+ }
118750
+ stopTimer() {
117129
118751
  if (this.timer) clearInterval(this.timer);
117130
118752
  this.timer = null;
117131
118753
  }
117132
118754
  startTimer() {
117133
- this.stop();
118755
+ this.stopTimer();
117134
118756
  this.timer = setInterval(() => void this.check(false), CHECK_INTERVAL_MS);
117135
118757
  this.timer.unref?.();
117136
118758
  }
@@ -117146,7 +118768,7 @@ var UpdateService = class {
117146
118768
  this.status = { ...this.status, auto: enabled };
117147
118769
  this.deps.writeAuto(enabled);
117148
118770
  if (!enabled) {
117149
- this.stop();
118771
+ this.stopTimer();
117150
118772
  this.emit();
117151
118773
  return this.current();
117152
118774
  }
@@ -117167,13 +118789,46 @@ var UpdateService = class {
117167
118789
  this.deps.writeAutoApply(enabled);
117168
118790
  this.emit();
117169
118791
  if (enabled) this.installIfAutomatic();
118792
+ else this.cancelRetry();
117170
118793
  return this.current();
117171
118794
  }
117172
- /** With `autoApply` on and a newer version known, install it — once, not over an install or a finished one */
118795
+ /**
118796
+ * With `autoApply` on and a newer version known, install it — once, not over an install or a
118797
+ * finished one. A failed install is tried again, but only on the backoff (#645): a check that
118798
+ * lands right after the failure does not start another `npm i -g`, and one after the delay does,
118799
+ * whether the retry timer or a person's Check now brought it.
118800
+ */
117173
118801
  installIfAutomatic() {
117174
118802
  if (!this.status.autoApply || !this.status.newer || !this.status.latest) return;
117175
- if (this.status.phase !== "idle") return;
117176
- this.apply();
118803
+ if (this.status.phase === "idle") {
118804
+ void this.apply();
118805
+ return;
118806
+ }
118807
+ if (this.status.phase !== "failed") return;
118808
+ if (this.retryDue()) void this.apply();
118809
+ else if (!this.retryTimer) this.scheduleRetry();
118810
+ }
118811
+ /**
118812
+ * A failed install may be retried once its backoff has passed — or at once for a release that
118813
+ * is not the one that failed: a new version is a new situation, and one attempt at it is cheap.
118814
+ */
118815
+ retryDue() {
118816
+ if (!this.retry || this.retry.version !== this.status.latest) return true;
118817
+ return this.deps.now() - this.retry.at >= retryDelay(this.retry.failures);
118818
+ }
118819
+ scheduleRetry() {
118820
+ this.cancelRetry();
118821
+ if (this.stopped || !this.retry || !this.status.autoApply || this.status.phase !== "failed") return;
118822
+ const delay = Math.max(0, retryDelay(this.retry.failures) - (this.deps.now() - this.retry.at));
118823
+ this.retryTimer = this.deps.schedule(() => {
118824
+ this.retryTimer = null;
118825
+ if (this.stopped || !this.status.autoApply || this.status.phase !== "failed" || !this.status.newer || !this.status.latest) return;
118826
+ void this.apply();
118827
+ }, delay);
118828
+ }
118829
+ cancelRetry() {
118830
+ this.retryTimer?.();
118831
+ this.retryTimer = null;
117177
118832
  }
117178
118833
  /**
117179
118834
  * Look at the registry, or answer with what we already know.
@@ -117183,32 +118838,36 @@ var UpdateService = class {
117183
118838
  * failed, nothing about a background failure interrupts anyone.
117184
118839
  */
117185
118840
  async check(force) {
118841
+ if (this.stopped || this.installing) return this.current();
117186
118842
  if (this.inFlight) return this.inFlight;
117187
118843
  if (!force && !this.status.auto) return this.current();
117188
118844
  if (!force && this.status.checkedAt !== null && !this.isDue()) return this.current();
117189
118845
  if (this.status.phase === "updating" || this.status.phase === "waiting_for_npm" || this.status.phase === "restart_required") return this.current();
117190
- this.status = { ...this.status, phase: "checking" };
118846
+ const failure3 = this.status.phase === "failed" ? { error: this.status.error } : null;
118847
+ if (!failure3) this.status = { ...this.status, phase: "checking" };
117191
118848
  this.emit();
117192
- this.inFlight = this.runCheck(force);
118849
+ this.inFlight = this.runCheck(force, failure3);
117193
118850
  try {
117194
118851
  return await this.inFlight;
117195
118852
  } finally {
117196
118853
  this.inFlight = null;
117197
118854
  }
117198
118855
  }
117199
- async runCheck(force) {
118856
+ async runCheck(force, failure3) {
117200
118857
  const res = await this.deps.fetchLatest(force ? FORCED_TIMEOUT_MS : SCHEDULED_TIMEOUT_MS);
118858
+ if (this.stopped || this.installing) return this.current();
117201
118859
  if (!res.ok) {
117202
- this.status = { ...this.status, phase: "idle", error: res.reason };
118860
+ this.status = { ...this.status, phase: failure3 ? "failed" : "idle", error: failure3 ? failure3.error : res.reason };
117203
118861
  this.emit();
117204
118862
  return this.current();
117205
118863
  }
118864
+ const latest = res.version;
117206
118865
  this.status = {
117207
118866
  ...this.status,
117208
- latest: res.version,
117209
- newer: isNewerVersion(res.version, this.status.current),
117210
- phase: "idle",
117211
- error: null,
118867
+ latest,
118868
+ newer: isNewerVersion(latest, this.status.current),
118869
+ phase: failure3 ? "failed" : "idle",
118870
+ error: failure3?.error ?? null,
117212
118871
  checkedAt: this.deps.now()
117213
118872
  };
117214
118873
  this.emit();
@@ -117219,37 +118878,95 @@ var UpdateService = class {
117219
118878
  * Install the newer version, then say so. **Does not restart the app** — that is the
117220
118879
  * window's step (#352).
117221
118880
  *
117222
- * Returns the moment the work starts. `npm i -g` regularly takes longer than the RPC
117223
- * deadline, and a caller that has already given up cannot be told how it went; the
117224
- * rest of the story arrives as `update_status` events.
118881
+ * Answers once the target is settled: after the fresh lookup (at most the forced deadline, plus
118882
+ * an older check it waits for), when `npm i -g` is about to start, or when the registry shows
118883
+ * nothing newer. Not when the install is done — `npm i -g` regularly takes longer than the RPC
118884
+ * deadline, and a caller that has already given up cannot be told how it went; the rest of the
118885
+ * story arrives as `update_status` events.
118886
+ *
118887
+ * With nothing newer known it refreshes first rather than refusing (#645): the stored answer
118888
+ * may be hours old, or there may be none at all with automatic checks off. "Up to date" is then
118889
+ * a result (`idle`, no error), never `failed` — a refusal recorded as a failure stayed on the
118890
+ * line through every later check.
117225
118891
  */
117226
118892
  apply() {
117227
- if (this.status.phase === "updating" || this.status.phase === "waiting_for_npm") return this.current();
117228
- const latest = this.status.latest;
117229
- if (!this.status.newer || !latest) {
117230
- this.status = { ...this.status, phase: "failed", error: "There is no newer version to install" };
117231
- this.emit();
117232
- return this.current();
117233
- }
117234
- this.status = { ...this.status, phase: "updating", error: null };
118893
+ if (this.stopped || this.installing || this.status.phase === "restart_required") return Promise.resolve(this.current());
118894
+ this.installing = true;
118895
+ this.cancelRetry();
118896
+ const known = this.status.newer && this.status.latest ? this.status.latest : null;
118897
+ this.status = { ...this.status, phase: known ? "updating" : "checking", error: null };
117235
118898
  this.emit();
117236
- void this.runApply(latest);
117237
- return this.current();
118899
+ return new Promise((settled) => void this.runApply(known, settled));
117238
118900
  }
117239
- async runApply(version2) {
118901
+ async runApply(known, settled) {
118902
+ let version2 = known;
117240
118903
  let packageInstalled = false;
118904
+ let answered = false;
118905
+ const answer = () => {
118906
+ if (answered) return;
118907
+ answered = true;
118908
+ settled(this.current());
118909
+ };
117241
118910
  const copy = this.deps.installedCopy();
117242
118911
  try {
118912
+ await this.inFlight;
118913
+ if (this.stopped) return;
118914
+ const age2 = this.status.checkedAt === null ? Infinity : this.deps.now() - this.status.checkedAt;
118915
+ if (version2 === null || age2 < 0 || age2 >= INSTALL_CHECK_MAX_AGE_MS) {
118916
+ const fresh = await this.deps.fetchLatest(FORCED_TIMEOUT_MS);
118917
+ if (this.stopped) return;
118918
+ if (fresh.ok) {
118919
+ const now = this.deps.now();
118920
+ const storedAge = this.status.checkedAt === null ? Infinity : now - this.status.checkedAt;
118921
+ const keepStored = version2 !== null && storedAge >= 0 && storedAge < STORED_RELEASE_MAX_AGE_MS && isNewerVersion(version2, fresh.version);
118922
+ const target = keepStored && version2 !== null ? version2 : fresh.version;
118923
+ version2 = target;
118924
+ this.status = { ...this.status, latest: target, newer: isNewerVersion(target, this.status.current), checkedAt: keepStored ? this.status.checkedAt : now };
118925
+ if (!this.status.newer) {
118926
+ this.status = { ...this.status, phase: "idle", error: null };
118927
+ return;
118928
+ }
118929
+ } else if (version2 === null) {
118930
+ this.status = { ...this.status, phase: "idle", error: fresh.reason };
118931
+ return;
118932
+ } else {
118933
+ this.status = { ...this.status, error: `Could not refresh latest (${fresh.reason}); using previously checked ${version2}.` };
118934
+ }
118935
+ }
118936
+ if (version2 === null) return;
118937
+ this.status = { ...this.status, phase: "updating" };
118938
+ this.emit();
118939
+ answer();
117243
118940
  await this.installPackage(version2);
118941
+ if (this.stopped) return;
117244
118942
  packageInstalled = true;
117245
- if (copy) await this.deps.run(APP_SLUG, ["install"]);
117246
- this.status = { ...this.status, phase: "restart_required", error: null };
118943
+ if (copy) {
118944
+ const copyVersion = this.deps.installedCopyVersion(copy);
118945
+ if (keepsInstalledCopy(version2, copyVersion)) {
118946
+ const notice = `The local build ${copyVersion} at ${copy} is newer than ${version2} and was kept.`;
118947
+ this.status = { ...this.status, error: this.status.error ? `${this.status.error} ${notice}` : notice };
118948
+ } else {
118949
+ await this.deps.run(APP_SLUG, ["install"]);
118950
+ }
118951
+ }
118952
+ if (this.stopped) return;
118953
+ this.status = { ...this.status, phase: "restart_required" };
118954
+ this.retry = null;
117247
118955
  } catch (e) {
118956
+ if (this.stopped) return;
117248
118957
  const reason = e.message;
117249
118958
  const error51 = packageInstalled ? `npm installed ${version2}, but the copy at ${copy} was not replaced (${reason}). Quit ${APP_NAME}, then run "${APP_SLUG} install" in a terminal.` : reason;
117250
118959
  this.status = { ...this.status, phase: "failed", error: error51 };
118960
+ const target = version2 ?? this.status.latest ?? "";
118961
+ this.retry = { version: target, failures: this.retry?.version === target ? this.retry.failures + 1 : 1, at: this.deps.now() };
118962
+ } finally {
118963
+ this.installing = false;
118964
+ if (!this.stopped) {
118965
+ this.emit();
118966
+ if (this.status.phase === "failed") this.installIfAutomatic();
118967
+ }
118968
+ answer();
117251
118969
  }
117252
- this.emit();
117253
118970
  }
117254
118971
  /**
117255
118972
  * A just-published release can be visible from `latest` before its package is on the npm edge
@@ -117259,16 +118976,20 @@ var UpdateService = class {
117259
118976
  */
117260
118977
  async installPackage(version2) {
117261
118978
  const started = this.deps.now();
118979
+ const notice = this.status.error;
117262
118980
  for (; ; ) {
118981
+ if (this.stopped) return;
117263
118982
  try {
117264
118983
  await this.deps.run("npm", ["i", "-g", "--prefer-online", `${APP_SLUG}@${version2}`]);
117265
118984
  return;
117266
118985
  } catch (err) {
118986
+ if (this.stopped) return;
117267
118987
  if (!npmHasNotServedVersion(err) || this.deps.now() - started >= NPM_RETRY_WINDOW_MS) throw err;
117268
- this.status = { ...this.status, phase: "waiting_for_npm", error: `Waiting for npm to serve ${version2}` };
118988
+ this.status = { ...this.status, phase: "waiting_for_npm", error: `Waiting for npm to serve ${version2}${notice ? `. ${notice}` : ""}` };
117269
118989
  this.emit();
117270
118990
  await this.deps.wait(NPM_RETRY_DELAY_MS);
117271
- this.status = { ...this.status, phase: "updating", error: null };
118991
+ if (this.stopped) return;
118992
+ this.status = { ...this.status, phase: "updating", error: notice };
117272
118993
  this.emit();
117273
118994
  }
117274
118995
  }
@@ -117284,6 +119005,9 @@ var UpdateService = class {
117284
119005
  function npmHasNotServedVersion(err) {
117285
119006
  return /(?:\bETARGET\b|\bnotarget\b)/i.test(err instanceof Error ? err.message : String(err));
117286
119007
  }
119008
+ function retryDelay(failures) {
119009
+ return AUTO_RETRY_DELAYS_MS[Math.min(Math.max(failures, 1), AUTO_RETRY_DELAYS_MS.length) - 1];
119010
+ }
117287
119011
 
117288
119012
  // packages/agent-host/src/agent-versions.ts
117289
119013
  var CHECK_INTERVAL_MS2 = 10 * 60 * 1e3;
@@ -117626,16 +119350,16 @@ var SessionSleeper = class {
117626
119350
  };
117627
119351
 
117628
119352
  // packages/agent-host/src/dev-services/instance-lock.ts
117629
- import { execFileSync as execFileSync4 } from "node:child_process";
117630
- import { readFileSync as readFileSync13, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "node:fs";
117631
- import { dirname as dirname10, join as join31 } from "node:path";
119353
+ import { execFileSync as execFileSync5 } from "node:child_process";
119354
+ import { readFileSync as readFileSync14, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "node:fs";
119355
+ import { dirname as dirname11, join as join32 } from "node:path";
117632
119356
  import Database2 from "better-sqlite3";
117633
119357
  var OWNERSHIP_FILE = "host-ownership.sqlite";
117634
119358
  var held = /* @__PURE__ */ new Set();
117635
119359
  function processStartTime(pid) {
117636
119360
  if (!hasPs()) return null;
117637
119361
  try {
117638
- const out = execFileSync4("ps", ["-o", "lstart=", "-p", String(pid)], {
119362
+ const out = execFileSync5("ps", ["-o", "lstart=", "-p", String(pid)], {
117639
119363
  encoding: "utf8",
117640
119364
  timeout: 2e3,
117641
119365
  stdio: ["ignore", "pipe", "ignore"],
@@ -117662,15 +119386,15 @@ function parseHolder(raw) {
117662
119386
  function acquireInstanceLock(dbPath2, startOf = processStartTime) {
117663
119387
  if (dbPath2 === ":memory:") return { ok: true, release: () => {
117664
119388
  } };
117665
- const lockPath = join31(dirname10(dbPath2), "host.lock");
119389
+ const lockPath = join32(dirname11(dbPath2), "host.lock");
117666
119390
  const readHolder = () => {
117667
119391
  try {
117668
- return parseHolder(readFileSync13(lockPath, "utf8"));
119392
+ return parseHolder(readFileSync14(lockPath, "utf8"));
117669
119393
  } catch {
117670
119394
  return null;
117671
119395
  }
117672
119396
  };
117673
- const owner = takeOwnership(join31(dirname10(dbPath2), OWNERSHIP_FILE));
119397
+ const owner = takeOwnership(join32(dirname11(dbPath2), OWNERSHIP_FILE));
117674
119398
  if (!owner) {
117675
119399
  const holder = readHolder();
117676
119400
  return { ok: false, heldByPid: holder && Number.isInteger(holder.pid) && holder.pid > 0 ? holder.pid : null, lockPath };
@@ -117683,7 +119407,7 @@ function acquireInstanceLock(dbPath2, startOf = processStartTime) {
117683
119407
  if (released) return;
117684
119408
  released = true;
117685
119409
  try {
117686
- if (parseHolder(readFileSync13(lockPath, "utf8")).pid === process.pid) unlinkSync3(lockPath);
119410
+ if (parseHolder(readFileSync14(lockPath, "utf8")).pid === process.pid) unlinkSync3(lockPath);
117687
119411
  } catch {
117688
119412
  }
117689
119413
  giveUp(owner);
@@ -118098,7 +119822,7 @@ async function runDrain(bound, deps) {
118098
119822
  }
118099
119823
 
118100
119824
  // packages/agent-host/src/links/install.ts
118101
- import { existsSync as existsSync22, readFileSync as readFileSync14 } from "node:fs";
119825
+ import { existsSync as existsSync22, readFileSync as readFileSync15 } from "node:fs";
118102
119826
  import { hostname as hostname3 } from "node:os";
118103
119827
  import { fileURLToPath as fileURLToPath6 } from "node:url";
118104
119828
  import { gzipSync } from "node:zlib";
@@ -118159,7 +119883,7 @@ async function verifiedPackage(name, version2, opts = {}) {
118159
119883
  }
118160
119884
 
118161
119885
  // packages/agent-host/src/links/tunnel.ts
118162
- import { spawn as spawn5 } from "node:child_process";
119886
+ import { spawn as spawn6 } from "node:child_process";
118163
119887
  import { createConnection as createConnection2, createServer as createServer3 } from "node:net";
118164
119888
  function parseConnectionLine(stdout) {
118165
119889
  const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -118423,7 +120147,7 @@ var SshTunnel = class {
118423
120147
  }
118424
120148
  run(args, stdin) {
118425
120149
  return new Promise((resolve12, reject) => {
118426
- const child = spawn5(this.opts.ssh ?? "ssh", args, { env: this.opts.env ?? process.env, stdio: [stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"] });
120150
+ const child = spawn6(this.opts.ssh ?? "ssh", args, { env: this.opts.env ?? process.env, stdio: [stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"] });
118427
120151
  if (stdin !== void 0) {
118428
120152
  child.stdin.on("error", () => {
118429
120153
  });
@@ -118482,7 +120206,7 @@ var SshTunnel = class {
118482
120206
  this.opts.target,
118483
120207
  ...keepAlive ? [keepAlive] : []
118484
120208
  ];
118485
- const child = spawn5(this.opts.ssh ?? "ssh", args, { env: this.opts.env ?? process.env, stdio: ["ignore", "ignore", "pipe"] });
120209
+ const child = spawn6(this.opts.ssh ?? "ssh", args, { env: this.opts.env ?? process.env, stdio: ["ignore", "ignore", "pipe"] });
118486
120210
  this.child = child;
118487
120211
  let stderr = "";
118488
120212
  child.stderr.on("data", (d2) => stderr += String(d2));
@@ -118776,7 +120500,7 @@ async function installRemote(o) {
118776
120500
  function bundled(name, source) {
118777
120501
  for (const url2 of [new URL(`./${name}`, import.meta.url), new URL(source, import.meta.url)]) {
118778
120502
  const file2 = fileURLToPath6(url2);
118779
- if (existsSync22(file2)) return readFileSync14(file2, "utf8");
120503
+ if (existsSync22(file2)) return readFileSync15(file2, "utf8");
118780
120504
  }
118781
120505
  throw new Error(`${name} is missing from this host`);
118782
120506
  }
@@ -118834,7 +120558,7 @@ async function removeAutostart(c) {
118834
120558
  }
118835
120559
 
118836
120560
  // packages/agent-host/src/links/probe.ts
118837
- import { spawn as spawn6 } from "node:child_process";
120561
+ import { spawn as spawn7 } from "node:child_process";
118838
120562
  var PROBE_MARK = "CENTRALU-PROBE";
118839
120563
  var PROBE_PS = [
118840
120564
  "$ErrorActionPreference = 'SilentlyContinue'",
@@ -118889,7 +120613,7 @@ function probeTarget(target, opts = {}) {
118889
120613
  clearTimeout(timer);
118890
120614
  resolve12(parseProbe(t, run));
118891
120615
  };
118892
- const child = spawn6(opts.ssh ?? "ssh", args, { env: opts.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] });
120616
+ const child = spawn7(opts.ssh ?? "ssh", args, { env: opts.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] });
118893
120617
  const timer = setTimeout(() => {
118894
120618
  child.kill("SIGTERM");
118895
120619
  finish2({ code: 255, stdout: "", stderr: `ssh to ${t} did not answer in time` });
@@ -118902,10 +120626,10 @@ function probeTarget(target, opts = {}) {
118902
120626
  }
118903
120627
 
118904
120628
  // packages/agent-host/src/links/ssh-config.ts
118905
- import { spawn as spawn7 } from "node:child_process";
118906
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync13 } from "node:fs";
120629
+ import { spawn as spawn8 } from "node:child_process";
120630
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16, statSync as statSync13 } from "node:fs";
118907
120631
  import { homedir as homedir13 } from "node:os";
118908
- import { isAbsolute as isAbsolute10, join as join32, normalize, parse as parse6, sep as sep12 } from "node:path";
120632
+ import { isAbsolute as isAbsolute10, join as join33, normalize, parse as parse6, sep as sep12 } from "node:path";
118909
120633
  var MAX_DEPTH2 = 16;
118910
120634
  function splitConfigLine(raw) {
118911
120635
  const m = /^\s*([^\s=#]+)\s*(?:=\s*)?([\s\S]*)$/.exec(raw);
@@ -118971,7 +120695,7 @@ function expandGlob(path2) {
118971
120695
  const next = [];
118972
120696
  for (const base2 of found) {
118973
120697
  if (!MAGIC.test(seg)) {
118974
- next.push(join32(base2, seg));
120698
+ next.push(join33(base2, seg));
118975
120699
  continue;
118976
120700
  }
118977
120701
  let names;
@@ -118983,7 +120707,7 @@ function expandGlob(path2) {
118983
120707
  const re2 = segmentRe(seg);
118984
120708
  for (const name of names.sort()) {
118985
120709
  if (name.startsWith(".") && !seg.startsWith(".")) continue;
118986
- if (re2.test(name)) next.push(join32(base2, name));
120710
+ if (re2.test(name)) next.push(join33(base2, name));
118987
120711
  }
118988
120712
  }
118989
120713
  found = next;
@@ -119013,7 +120737,7 @@ function readSshConfig(opts) {
119013
120737
  }
119014
120738
  let text2;
119015
120739
  try {
119016
- text2 = readFileSync15(file2, "utf8");
120740
+ text2 = readFileSync16(file2, "utf8");
119017
120741
  } catch (err) {
119018
120742
  if (depth > 0 && err.code !== "ENOENT") notes.push(`${tildePath(file2, opts.home)} could not be read`);
119019
120743
  return;
@@ -119038,11 +120762,11 @@ function readSshConfig(opts) {
119038
120762
  }
119039
120763
  for (const arg of split2.args) {
119040
120764
  let path2 = arg;
119041
- if (path2.startsWith("~/")) path2 = join32(opts.home, path2.slice(2));
120765
+ if (path2.startsWith("~/")) path2 = join33(opts.home, path2.slice(2));
119042
120766
  else if (path2.startsWith("~")) {
119043
120767
  notes.push(`An Include of another user's folder (${where(file2, n + 1)}) was not read`);
119044
120768
  continue;
119045
- } else if (!isAbsolute10(path2)) path2 = join32(opts.home, ".ssh", path2);
120769
+ } else if (!isAbsolute10(path2)) path2 = join33(opts.home, ".ssh", path2);
119046
120770
  for (const f of expandGlob(path2)) read(f, depth + 1);
119047
120771
  }
119048
120772
  }
@@ -119077,7 +120801,7 @@ function resolveAlias(alias, opts = {}) {
119077
120801
  resolve12(r);
119078
120802
  };
119079
120803
  const none = (error51) => ({ hostName: null, user: null, port: null, error: error51 });
119080
- const child = spawn7(opts.ssh ?? "ssh", args, { env: opts.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] });
120804
+ const child = spawn8(opts.ssh ?? "ssh", args, { env: opts.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] });
119081
120805
  const timer = setTimeout(() => {
119082
120806
  child.kill("SIGKILL");
119083
120807
  finish2(none("ssh -G did not answer"));
@@ -119092,7 +120816,7 @@ var MAX_HOSTS = 100;
119092
120816
  var RESOLVE_AT_ONCE = 8;
119093
120817
  async function listSshHosts(opts = {}) {
119094
120818
  const home = opts.home ?? homedir13();
119095
- const file2 = opts.configFile ?? join32(home, ".ssh", "config");
120819
+ const file2 = opts.configFile ?? join33(home, ".ssh", "config");
119096
120820
  const read = readSshConfig({ file: file2, home });
119097
120821
  const notes = [...read.notes];
119098
120822
  const listed = read.hosts.slice(0, MAX_HOSTS);
@@ -119455,7 +121179,10 @@ var Qualifier = class {
119455
121179
  const sid = typeof e.sessionId === "string" ? this.id(e.sessionId) : void 0;
119456
121180
  const base2 = { ...e, ...sid !== void 0 ? { sessionId: sid } : {} };
119457
121181
  switch (e.type) {
121182
+ // Another machine's agent asking its own windows to apply (#665) must never relaunch the hub's window
119458
121183
  case "update_status":
121184
+ case "update_apply_request":
121185
+ case "update_apply_go":
119459
121186
  case "themes_changed":
119460
121187
  case "app_state_changed":
119461
121188
  case "agent_versions":
@@ -120480,7 +122207,7 @@ function linkedSessions(machines, route = notRouted, hubName2) {
120480
122207
  async updateSettings(sessionId, s) {
120481
122208
  const m = owner(sessionId);
120482
122209
  if (!m) return { ok: false, error: `Not a session of a linked machine: ${sessionId}` };
120483
- return attempt(m, async () => ({ info: await route("agents.updateSettings", { sessionId, ...s }) }));
122210
+ return attempt(m, async () => ({ info: await route("agents.updateSettings", { sessionId, ...s, by: "orchestrator" }) }));
120484
122211
  },
120485
122212
  async findProjects(ref) {
120486
122213
  const wanted = ref.trim();
@@ -120875,6 +122602,9 @@ var ROUTES = {
120875
122602
  "updates.setAuto": HUB,
120876
122603
  "updates.setAutoApply": HUB,
120877
122604
  "updates.apply": HUB,
122605
+ // The hub's own windows answer the hub's own host (#665); a linked machine's events never ask them
122606
+ "updates.answerApply": HUB,
122607
+ "updates.reportApply": HUB,
120878
122608
  "machines.list": HUB,
120879
122609
  "machines.add": HUB,
120880
122610
  "machines.sshHosts": HUB,
@@ -121198,7 +122928,7 @@ function storeMirror(store2) {
121198
122928
  var start = new StartSequence();
121199
122929
  start.at("launch variables");
121200
122930
  var launch = takeLaunchEnv();
121201
- var BUILD = true ? "5dca7b8" : "dev";
122931
+ var BUILD = true ? "29ffab5" : "dev";
121202
122932
  var { values } = parseArgs({
121203
122933
  options: {
121204
122934
  port: { type: "string", default: "5175" },
@@ -121219,17 +122949,17 @@ start.at("data folder");
121219
122949
  var movedNote = null;
121220
122950
  function defaultDbPath() {
121221
122951
  const isDev = process.env.CC_DEV === "1";
121222
- const dir = join33(homedir14(), isDev ? DATA_DIR_DEV : DATA_DIR);
121223
- const legacy = join33(homedir14(), isDev ? DATA_DIR_LEGACY.dev : DATA_DIR_LEGACY.prod);
122952
+ const dir = join34(homedir14(), isDev ? DATA_DIR_DEV : DATA_DIR);
122953
+ const legacy = join34(homedir14(), isDev ? DATA_DIR_LEGACY.dev : DATA_DIR_LEGACY.prod);
121224
122954
  movedNote = migrateLegacyDataDir(legacy, dir) ? `[agent-host] data folder moved: ${legacy} -> ${dir}` : null;
121225
122955
  mkdirSync11(dir, { recursive: true });
121226
122956
  process.env.CC_DATA_DIR = dir;
121227
- return join33(dir, "store.db");
122957
+ return join34(dir, "store.db");
121228
122958
  }
121229
122959
  var dbPath = values.memory ? ":memory:" : values.db ?? defaultDbPath();
121230
122960
  start.at("log");
121231
122961
  var stopLog = dbPath === ":memory:" ? () => {
121232
- } : teeStderrToFile(hostLogPath(dirname11(dbPath)));
122962
+ } : teeStderrToFile(hostLogPath(dirname12(dbPath)));
121233
122963
  if (dbPath !== ":memory:") console.error(startupBanner({ build: BUILD, db: dbPath, pid: process.pid }));
121234
122964
  if (movedNote) console.error(movedNote);
121235
122965
  start.at("path");
@@ -121285,7 +123015,7 @@ try {
121285
123015
  }
121286
123016
  var adapters = createAdapters();
121287
123017
  start.at("keeper children");
121288
- var held2 = launch.underKeeper ? await connectHeldChildren(dirname11(dbPath)) : null;
123018
+ var held2 = launch.underKeeper ? await connectHeldChildren(dirname12(dbPath)) : null;
121289
123019
  start.at("services");
121290
123020
  var screens = new Gate();
121291
123021
  var events = new Gate();
@@ -121302,7 +123032,7 @@ var mgr = new SessionManager(
121302
123032
  },
121303
123033
  (sessionId) => bridgeAddress(launch.frontDoor, port, token, sessionId),
121304
123034
  // Worktrees are created next to the data folder — dev and the packaged app never touch each other's worktrees
121305
- join33(dirname11(dbPath), "worktrees"),
123035
+ join34(dirname12(dbPath), "worktrees"),
121306
123036
  held2 ? { processes: held2.processes, keptSessions: held2.kept.sessionIds } : {}
121307
123037
  );
121308
123038
  mgr.useProjectsHome(() => projectsHome());
@@ -121336,7 +123066,7 @@ if (held2) {
121336
123066
  }
121337
123067
  var AUTO_UPDATE_CHECK_KEY = "updates.auto";
121338
123068
  var AUTO_APPLY_UPDATES_KEY = "updates.autoApply";
121339
- var themes = new ThemeFiles(join33(dataRoot(), "themes"), () => events.push({ type: "themes_changed" }));
123069
+ var themes = new ThemeFiles(join34(dataRoot(), "themes"), () => events.push({ type: "themes_changed" }));
121340
123070
  await themes.start().catch((e) => console.error(`[themes] the themes folder is unavailable: ${e.message}`));
121341
123071
  var updates = new UpdateService((status) => events.push({ type: "update_status", status }), {
121342
123072
  // On by default when nothing has been saved yet. Since the check is read-only and every failure
@@ -121350,6 +123080,8 @@ var updates = new UpdateService((status) => events.push({ type: "update_status",
121350
123080
  writeAutoApply: (enabled) => store.setAppSetting(AUTO_APPLY_UPDATES_KEY, String(enabled))
121351
123081
  });
121352
123082
  mgr.useUpdates(updates);
123083
+ var updateApply = new UpdateApplyBroker({ push: (e) => events.push(e), windows: () => server.clientCount });
123084
+ mgr.useUpdateApply(updateApply);
121353
123085
  var AUTO_APPLY_AGENT_VERSIONS_KEY = "agents.autoApplyVersions";
121354
123086
  var AGENT_VERSIONS_SEEN_KEY = "agents.versionsSeen";
121355
123087
  var agentVersions = new AgentVersionService({
@@ -121457,7 +123189,8 @@ var router = new Router({
121457
123189
  heldPids: held2?.heldPids,
121458
123190
  machines: links,
121459
123191
  stopHost: launch.startedByServe && !launch.underKeeper ? () => stopFromRequest() : void 0,
121460
- activity: () => activityCounts(activity())
123192
+ activity: () => activityCounts(activity()),
123193
+ updateApply
121461
123194
  }),
121462
123195
  machines: () => links.all()
121463
123196
  });
@@ -121468,7 +123201,10 @@ var server = new HostServer({
121468
123201
  allowedOrigins,
121469
123202
  onRpc: router.handle,
121470
123203
  // A window's connection ended: the pinned views it leased wait for a claim, then close (#392, view-host.ts)
121471
- onClientGone: (client) => views.holderGone(client),
123204
+ onClientGone: (client) => {
123205
+ views.holderGone(client);
123206
+ updateApply.clientGone(client);
123207
+ },
121472
123208
  // Every HTTP route sits behind this secret (transport/http.ts)
121473
123209
  http: { secret: httpSecret, routes: views.routes },
121474
123210
  // Which build this is and where it came from, in every hello_ok (#280, keeper-link.ts)
@@ -121555,7 +123291,7 @@ var endings = wireEndings({
121555
123291
  heldChildren: held2 !== null,
121556
123292
  leave: (mode, handOver) => void shutdown.leave(mode, handOver),
121557
123293
  force: (why) => shutdown.force(why),
121558
- record: crashRecorder(join33(dirname11(dbPath), "host-errors.log"), (line) => console.error(line)),
123294
+ record: crashRecorder(join34(dirname12(dbPath), "host-errors.log"), (line) => console.error(line)),
121559
123295
  stdin: process.stdin,
121560
123296
  watchParent: values["watch-parent"] === true,
121561
123297
  log: (line) => console.error(line)