@centralu/host-linux-arm64 0.1.0-beta.22 → 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
@@ -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.22";
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;
@@ -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'",
@@ -64087,6 +64189,13 @@ function installedCopyPath(platform = HOST_PLATFORM, env = process.env, home = h
64087
64189
  }
64088
64190
  return posix2.join(home, ".local/share/applications", `${APP_SLUG}.desktop`);
64089
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
+ }
64090
64199
 
64091
64200
  // packages/agent-host/src/dev-services/fs.ts
64092
64201
  var MAX_TEXT = 2e6;
@@ -98208,7 +98317,7 @@ var OriginPorts = class {
98208
98317
  // packages/agent-host/src/dev-services/worktree-recovery.ts
98209
98318
  import { randomUUID as randomUUID6 } from "node:crypto";
98210
98319
  import { createReadStream, existsSync as existsSync13 } from "node:fs";
98211
- import { chmod, lstat as lstat3, mkdir as mkdir2, mkdtemp, readFile, readdir as readdir2, realpath as realpath3, rm as rm2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
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";
98212
98321
  import { tmpdir as tmpdir2 } from "node:os";
98213
98322
  import { join as join16, resolve as resolve2 } from "node:path";
98214
98323
  var WORKTREE_RECOVERY_MAX_BYTES = 256 * 1024 * 1024;
@@ -98217,8 +98326,51 @@ function worktreeRecoveryDir(sessionId) {
98217
98326
  if (!isSessionId(sessionId)) throw new Error("Invalid session id for worktree recovery");
98218
98327
  return join16(dataRoot(), "worktree-recovery", sessionId);
98219
98328
  }
98220
- async function clearWorktreeRecovery(sessionId) {
98221
- 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}`]);
98222
98374
  }
98223
98375
  async function* gitRecords(repo, args, input) {
98224
98376
  const dir = await mkdtemp(join16(tmpdir2(), "cc-recovery-git-"));
@@ -98453,11 +98605,37 @@ async function restoreWorktreeRecovery(sessionId, repo, path2, branch, recovery,
98453
98605
  }
98454
98606
  if (!existsSync13(path2)) {
98455
98607
  await rm2(join16(dir, "patches-applied"), { force: true });
98456
- if (await runGit(repo, ["show-ref", "--verify", `refs/heads/${branch}`]).then(() => true).catch(() => false)) {
98457
- branch = `recovered/${sessionId}`;
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
+ }
98458
98630
  }
98459
98631
  if (root3.bundle) await runGit(repo, ["fetch", join16(dir, "0.bundle"), ...root3.refs ?? ["HEAD"]], { allowRepoPrograms: trust.trusted === true });
98460
- 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 });
98461
98639
  if (root3.patch || root3.staged) {
98462
98640
  try {
98463
98641
  if (root3.staged) await runGit(path2, ["apply", "--cached", "--binary", join16(dir, "0.staged.patch")]);
@@ -98580,11 +98758,17 @@ Ordinary checks follow npm, including dist-tag rollbacks. Installing reuses a su
98580
98758
  from the last minute or checks again. Only while installing can a higher stored version seen less
98581
98759
  than an hour ago win over the fresh tag. Older observations yield to npm. If the check fails, it
98582
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.
98583
98764
  Once installed, the desktop app offers "Apply now": it relaunches into the new version, and running
98584
98765
  agents, terminals and commands keep going. "Apply updates automatically when idle" (off by default)
98585
98766
  installs a new version without a click and applies it once no session is working or waiting, no
98586
- terminal or command is running, and the person is not typing.
98587
- 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.`
98588
98772
  };
98589
98773
  function gist(description) {
98590
98774
  const first = description.split(/ — |\. /)[0] ?? description;
@@ -98643,8 +98827,8 @@ function trustedJsonText(value) {
98643
98827
  var ORCHESTRATOR_TOOLS = [
98644
98828
  {
98645
98829
  name: "update_centralu",
98646
- description: "Checks for or installs a Centralu update. Installing never changes the person's automatic-update setting.",
98647
- 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"]) })
98648
98832
  },
98649
98833
  {
98650
98834
  name: "list_sessions",
@@ -98782,8 +98966,9 @@ var BUILDER_TOOL_NAMES = ["check"];
98782
98966
  var READER_TOOLS = [
98783
98967
  {
98784
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
98785
98970
  description: "Updates Centralu with consent.",
98786
- schema: external_exports.object({ action: external_exports.enum(["check", "install"]) }),
98971
+ schema: external_exports.object({ action: external_exports.enum(["check", "install", "apply"]) }),
98787
98972
  // Updates are rare (#581); keep the definition behind Claude's tool search like app_guide.
98788
98973
  deferred: true
98789
98974
  },
@@ -98943,6 +99128,11 @@ function askProjectText(r) {
98943
99128
  }
98944
99129
  function agentRunText(r) {
98945
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
+ }
98946
99136
  const who = `the ${r.tool} session "${r.sessionName}" [${r.sessionId}]`;
98947
99137
  switch (r.state) {
98948
99138
  case "working":
@@ -98950,6 +99140,7 @@ function agentRunText(r) {
98950
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.`
98951
99141
  };
98952
99142
  case "stopped":
99143
+ if (!r.sessionName) return { text: `Withdrew request ${r.sessionId}: its card is gone and no session was started.` };
98953
99144
  return { text: `Stopped ${who}.` };
98954
99145
  case "deleted":
98955
99146
  if (r.branch === void 0) return { text: `Moved ${who} to the trash. Tell the person in one line.` };
@@ -98975,13 +99166,25 @@ agent_session(sessionId="${r.sessionId}", action="send", text=\u2026) gives it m
98975
99166
  async function runOrchestratorTool(tools, name, args, caller = { sessionId: null, profile: "human" }, signal) {
98976
99167
  if (name === "update_centralu") {
98977
99168
  const action = args.action;
98978
- if (action !== "check" && action !== "install") return { text: "Give action: check or install.", isError: true };
98979
- 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
+ }
98980
99178
  if (!r.ok) return { text: `Centralu update did not start: ${r.error ?? "unavailable"}`, isError: true };
98981
99179
  const s = r.status;
98982
99180
  if (!s) return { text: "Centralu update is unavailable.", isError: true };
98983
99181
  if (action === "check") return { text: s.newer && s.latest ? `Centralu ${s.latest} is available.` : s.error ?? "Centralu is up to date." };
98984
- 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." };
98985
99188
  }
98986
99189
  if (name === "list_sessions") return listSessionsText(tools, caller);
98987
99190
  if (name === "centralu_commands" || name === "centralu_help" || name === "centralu_run") return runCommandTool(tools, name, args, caller, signal);
@@ -101161,8 +101364,8 @@ function orchestratorMemory(reader, sessionId) {
101161
101364
 
101162
101365
  // packages/agent-host/src/sessions/manager.ts
101163
101366
  import { homedir as homedir12 } from "node:os";
101164
- import { basename as basename5, isAbsolute as isAbsolute9, join as join29, relative as relative8, resolve as resolve11, sep as sep11 } from "node:path";
101165
- 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";
101166
101369
  import { exec as exec6 } from "node:child_process";
101167
101370
 
101168
101371
  // packages/agent-host/src/adapters/claude/index.ts
@@ -102365,6 +102568,28 @@ var ClaudeBackgroundTracker = class {
102365
102568
  recover(messages2) {
102366
102569
  return this.schedules.recover(messages2) ? [this.event()] : [];
102367
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
+ }
102368
102593
  /** Drops the wake-ups whose time has passed; the new level when that changed anything */
102369
102594
  tick() {
102370
102595
  return this.schedules.prune() ? [this.event()] : [];
@@ -102392,7 +102617,7 @@ var ClaudeBackgroundTracker = class {
102392
102617
  this.live.push(id2);
102393
102618
  this.seen.add(id2);
102394
102619
  }
102395
- return [this.event()];
102620
+ return [this.event(void 0, true)];
102396
102621
  }
102397
102622
  if (subtype === "task_started" || subtype === "task_updated") {
102398
102623
  const id2 = str4(m.task_id);
@@ -102458,9 +102683,10 @@ var ClaudeBackgroundTracker = class {
102458
102683
  status: "running"
102459
102684
  };
102460
102685
  }
102461
- event(ended) {
102686
+ event(ended, snapshot2) {
102462
102687
  return {
102463
102688
  type: "background_tasks",
102689
+ ...snapshot2 ? { snapshot: snapshot2 } : {},
102464
102690
  sessionId: this.sessionId,
102465
102691
  live: [...this.live.map((id2) => this.entry(id2, this.info.get(id2))), ...this.schedules.live()],
102466
102692
  ...ended ? { ended } : {}
@@ -103047,6 +103273,7 @@ var ClaudeSession = class {
103047
103273
  this.stream.goal.seed(opts.knownGoal);
103048
103274
  if (opts.processSource?.adopt) this.externalId = opts.resumeExternalId ?? null;
103049
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);
103050
103277
  }
103051
103278
  sessionId;
103052
103279
  opts;
@@ -106919,6 +107146,33 @@ function registeredTool(adapters2, preferred) {
106919
107146
  return adapters2.keys().next().value ?? null;
106920
107147
  }
106921
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
+
106922
107176
  // packages/agent-host/src/dev-services/store.ts
106923
107177
  import Database from "better-sqlite3";
106924
107178
  import { existsSync as existsSync18, readFileSync as readFileSync12 } from "node:fs";
@@ -108237,6 +108491,70 @@ var Store = class _Store {
108237
108491
  WHERE asked_by_session_id IS NULL AND parent_session_id IS NOT NULL AND worktree_path IS NOT NULL
108238
108492
  `);
108239
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
+ }
108240
108558
  }
108241
108559
  ];
108242
108560
  }
@@ -108430,11 +108748,16 @@ var Store = class _Store {
108430
108748
  this.checkpoint();
108431
108749
  this.db.close();
108432
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
+ */
108433
108756
  addProject(p) {
108434
108757
  this.db.prepare(
108435
- `INSERT INTO projects (id, path, name, created_at) VALUES (?, ?, ?, ?)
108758
+ `INSERT INTO projects (id, path, name, created_at, trusted) VALUES (?, ?, ?, ?, ?)
108436
108759
  ON CONFLICT(path) DO UPDATE SET name = excluded.name`
108437
- ).run(p.id, p.path, p.name, Date.now());
108760
+ ).run(p.id, p.path, p.name, Date.now(), p.trusted ? 1 : 0);
108438
108761
  }
108439
108762
  /**
108440
108763
  * `commands` is deliberately not in here (issue #44) — `projectCommands` answers that one.
@@ -109006,6 +109329,8 @@ var Store = class _Store {
109006
109329
  this.db.prepare(`DELETE FROM commit_sessions WHERE session_id = ?`).run(sessionId);
109007
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);
109008
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);
109009
109334
  this.db.prepare(`DELETE FROM sessions WHERE id = ? AND deleted_at IS NOT NULL`).run(sessionId);
109010
109335
  return true;
109011
109336
  });
@@ -109593,6 +109918,39 @@ var Store = class _Store {
109593
109918
  ).all();
109594
109919
  return rows.filter((r) => ProjectConsentKind.safeParse(r.kind).success);
109595
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
+ }
109596
109954
  // ── Linked machines (#82, v46) ─────────────────────────────────────────────────────────────
109597
109955
  listLinkedMachines() {
109598
109956
  const rows = this.db.prepare(
@@ -109762,9 +110120,9 @@ async function gitStatusFiles(cwd, trust = {}) {
109762
110120
  if (entry.startsWith("1 ") || entry.startsWith("2 ")) {
109763
110121
  const parts = entry.split(" ");
109764
110122
  const xy = parts[1] ?? "..";
109765
- const rename5 = entry.startsWith("2 ");
109766
- const path2 = parts.slice(rename5 ? 9 : 8).join(" ");
109767
- if (rename5) i++;
110123
+ const rename6 = entry.startsWith("2 ");
110124
+ const path2 = parts.slice(rename6 ? 9 : 8).join(" ");
110125
+ if (rename6) i++;
109768
110126
  if (!path2) continue;
109769
110127
  const [x, y] = [xy[0] ?? ".", xy[1] ?? "."];
109770
110128
  if (x !== ".") out.push({ path: path2, staged: true, status: mapStatus(x) });
@@ -110448,12 +110806,72 @@ function attachCommitSessions(commits, records, nameOf) {
110448
110806
 
110449
110807
  // packages/agent-host/src/sessions/manager.ts
110450
110808
  function withTimeout(p, ms3, label = "A call") {
110809
+ let timer;
110451
110810
  return Promise.race([
110452
110811
  p,
110453
- new Promise(
110454
- (_, reject) => setTimeout(() => reject(new Error(`${label} did not finish within ${Math.round(ms3 / 1e3)}s`)), ms3)
110455
- )
110456
- ]);
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
+ }
110457
110875
  }
110458
110876
  var HISTORY_LIMIT = 200;
110459
110877
  var SYNC_LIMIT = 600;
@@ -110490,10 +110908,26 @@ function isCompactionMarker(row) {
110490
110908
  return row.kind === "marker" && p?.type === "compaction" && !p.failed;
110491
110909
  }
110492
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
+ }
110493
110926
  var EXTERNAL_LIST_LIMIT = 200;
110494
110927
  var HOST_READ_MAX_LINES = 200;
110495
110928
  var HOST_RECALL_MAX_HITS = 50;
110496
110929
  var HUB_SENDER = "_hub-orchestrator";
110930
+ var HOST_SENDER = "_centralu";
110497
110931
  var builderKey = (ref) => `${ref.projectId ?? "_user"}/${ref.appId}`;
110498
110932
  var APP_BUILDERS_KEY = "apps.builders";
110499
110933
  var SessionManager = class {
@@ -110526,8 +110960,29 @@ var SessionManager = class {
110526
110960
  store2.upsertSession(fixed);
110527
110961
  }
110528
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
+ }
110529
110983
  void this.sweepOrphanHandoffNotes().catch(() => {
110530
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}`));
110531
110986
  this.nameUnnamedWorktrees();
110532
110987
  for (const pid of new Set(
110533
110988
  [...this.meta.values()].filter((s) => s.worktree && s.projectId).map((s) => s.projectId)
@@ -110598,8 +111053,30 @@ var SessionManager = class {
110598
111053
  cacheLedger = new CacheLedger();
110599
111054
  /** Why a session's process is being restarted, for the cache's rebuild cause; taken when the new process comes up */
110600
111055
  restartReasons = /* @__PURE__ */ new Map();
110601
- /** 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
+ */
110602
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
+ }
110603
111080
  /**
110604
111081
  * Session currently being resumed -> its promise.
110605
111082
  *
@@ -110629,9 +111106,31 @@ var SessionManager = class {
110629
111106
  definitionsRestartAfterTurn = /* @__PURE__ */ new Set();
110630
111107
  /** A kept process was started with adapter settings from an older host build (#589). */
110631
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();
110632
111127
  updates = null;
110633
111128
  /** Sessions allowed to update once per host; an "always" answer adds this session only. */
110634
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();
110635
111134
  /**
110636
111135
  * Where external apps are attached to a session (M4 A-5). It is null on a host with no runtime
110637
111136
  * (most tests), and in that case the session accepts no apps — it is optional, like any other
@@ -110681,6 +111180,21 @@ var SessionManager = class {
110681
111180
  * `readGrants`: a restart asks again. The project-wide always is a stored consent instead.
110682
111181
  */
110683
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;
110684
111198
  /**
110685
111199
  * What a session may read beyond its folder because another project handed it back (#371 part B) — session id ->
110686
111200
  * resolved paths (files, or output folders). Live only: the grant lasts while this host serves the session, and a
@@ -110733,13 +111247,18 @@ var SessionManager = class {
110733
111247
  { code: "internal" }
110734
111248
  );
110735
111249
  }
110736
- 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 = {}) {
110737
111256
  if (!existsSync19(path2) || !statSync12(path2).isDirectory()) {
110738
111257
  throw Object.assign(new Error(`Directory not found: ${path2}`), { code: "internal" });
110739
111258
  }
110740
111259
  const existing = this.store.findProjectByPath(path2);
110741
111260
  const id2 = existing?.id ?? randomUUID10();
110742
- 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 } : {} });
110743
111262
  return this.projectInfo(id2, path2);
110744
111263
  }
110745
111264
  /**
@@ -110784,7 +111303,7 @@ var SessionManager = class {
110784
111303
  /**
110785
111304
  * Makes a new project (#515): a folder named `name` in `parent` (this machine's `~/Centralu` when absent), empty, or a
110786
111305
  * clone of `source` made with this machine's git and saved sign-ins, then registers it. Never reuses anything
110787
- * 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
110788
111307
  * and no project is registered.
110789
111308
  */
110790
111309
  async createProject(name, parent, source) {
@@ -110801,7 +111320,7 @@ var SessionManager = class {
110801
111320
  }
110802
111321
  }
110803
111322
  console.error(`[projects] made ${target.path}`);
110804
- const info = await this.addProject(target.path);
111323
+ const info = await this.addProject(target.path, { trusted: !source });
110805
111324
  this.emit({ type: "projects_changed" });
110806
111325
  return info;
110807
111326
  }
@@ -110895,13 +111414,29 @@ var SessionManager = class {
110895
111414
  }
110896
111415
  /**
110897
111416
  * Project trust (M4 A-2, plan decision 3). The app runtime reads it fresh from the store every
110898
- * time, so this only writes it — telling it to rescan is the RPC layer's job (this layer does not
110899
- * 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.
110900
111424
  */
110901
111425
  setProjectTrusted(projectId, trusted) {
110902
111426
  if (!this.store.setProjectTrusted(projectId, trusted)) {
110903
111427
  throw Object.assign(new Error(`Project not found: ${projectId}`), { code: "internal" });
110904
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);
110905
111440
  }
110906
111441
  /**
110907
111442
  * Is this project trusted (decision 3, #92) — passed to the adapter when launching a session. False
@@ -111351,6 +111886,7 @@ var SessionManager = class {
111351
111886
  let handle;
111352
111887
  if (params.builderOf) this.setBuilder(params.builderOf, id2);
111353
111888
  const apps = this.appsFor(info);
111889
+ const profile = toolProfileFor(this.profileFacts(info, !!params.builderOf));
111354
111890
  const from = this.handleSink();
111355
111891
  this.unborn.set(id2, []);
111356
111892
  try {
@@ -111374,14 +111910,14 @@ var SessionManager = class {
111374
111910
  ...this.handoffReadDirs(params.projectId, !!params.handoff?.fromSessionId),
111375
111911
  // Paths another project handed back to this session (#371), asked on every read
111376
111912
  mayRead: (path2) => this.mayRead(id2, path2, cwd),
111377
- // An orchestrator gets all of them; an ordinary session the reader set (#320, #561). The same rule runs again
111378
- // on every wake
111379
- orchestratorTools: info.kind === "orchestrator" ? this.orchestratorToolsFor(id2) : params.builderOf ? this.orchestratorToolsFor(id2) : this.readsOwnProject(info) ? this.readerToolsFor(id2) : void 0,
111380
- 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,
111381
111917
  systemPromptAppend: info.kind === "orchestrator" ? orchestratorRole() + this.skillsPrompt() : info.roleAppend ?? void 0,
111382
111918
  // The path back to the host — used by the orchestrator tools' bridge and an external app's bridge
111383
111919
  // (M4 A-5)
111384
- 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,
111385
111921
  // An MCP server the person approved is loaded here as a user-folder app (M4 A-7, decision 4)
111386
111922
  apps,
111387
111923
  // The answer schema an app supplied when it asked for this (M4 D-1) — Claude gets it on this
@@ -111417,6 +111953,7 @@ var SessionManager = class {
111417
111953
  }
111418
111954
  }
111419
111955
  this.handles.set(id2, handle);
111956
+ this.rememberBackgroundTasks(info);
111420
111957
  this.cacheLedger.note(id2, params.resumeExternalId ? "new_process" : "cold_start");
111421
111958
  this.running.set(id2, {
111422
111959
  model: info.model,
@@ -111621,16 +112158,86 @@ var SessionManager = class {
111621
112158
  * The keeper as a `ProcessSource` for one session, when this host runs under one (#280 step 2). `version` is the CLI
111622
112159
  * version a process spawned now is started from, written on its keeper tag (#297).
111623
112160
  */
111624
- processSourceFor(sessionId, tool, adopt, version2) {
112161
+ processSourceFor(sessionId, tool, adopt, version2, kept) {
111625
112162
  const host = this.keeperOpts.processes;
111626
112163
  if (!host) return {};
111627
112164
  return {
111628
112165
  processSource: {
111629
112166
  spawn: (spec) => host.spawn(sessionId, tool, spec, version2 ?? void 0),
111630
- ...adopt ? { adopt: { process: adopt, openCalls: this.openToolCalls(sessionId) } } : {}
112167
+ ...adopt ? { adopt: { process: adopt, openCalls: this.openToolCalls(sessionId), backgroundTasks: kept ?? [] } } : {}
111631
112168
  }
111632
112169
  };
111633
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
+ }
111634
112241
  /** Tool calls the store holds for this session with no result after them (the newest 200 rows) */
111635
112242
  openToolCalls(sessionId) {
111636
112243
  const open6 = /* @__PURE__ */ new Map();
@@ -111673,7 +112280,7 @@ var SessionManager = class {
111673
112280
  );
111674
112281
  }
111675
112282
  async doResumeSession(sessionId, adopt) {
111676
- 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" });
111677
112284
  const m = this.meta.get(sessionId);
111678
112285
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
111679
112286
  if (this.handles.has(sessionId)) return { session: m, resumed: true };
@@ -111715,6 +112322,7 @@ var SessionManager = class {
111715
112322
  }
111716
112323
  const apps = this.appsFor(m);
111717
112324
  const builds = this.builderRefOf(m) !== null;
112325
+ const profile = toolProfileFor(this.profileFacts(m, builds));
111718
112326
  const from = this.handleSink();
111719
112327
  const launched = {
111720
112328
  model: m.model,
@@ -111724,12 +112332,13 @@ var SessionManager = class {
111724
112332
  permissionPreset: m.permissionPreset
111725
112333
  };
111726
112334
  if (!adopt) m.agentVersion = this.versionHint?.(tool) ?? null;
112335
+ const kept = adopt ? this.keptBackgroundTasks(sessionId) : null;
111727
112336
  try {
111728
112337
  const creating = adapter.createSession(
111729
112338
  {
111730
112339
  sessionId,
111731
112340
  cwd,
111732
- ...this.processSourceFor(sessionId, tool, adopt, m.agentVersion),
112341
+ ...this.processSourceFor(sessionId, tool, adopt, m.agentVersion, kept),
111733
112342
  model: launched.model ?? void 0,
111734
112343
  effort: launched.effort ?? void 0,
111735
112344
  verbosity: launched.verbosity ?? void 0,
@@ -111761,11 +112370,8 @@ var SessionManager = class {
111761
112370
  * has just been attached passes this check the next time it wakes and receives the
111762
112371
  * child-only tools and role.
111763
112372
  */
111764
- orchestratorTools: m.kind === "orchestrator" ? this.orchestratorToolsFor(sessionId) : m.kind === "coordinator" ? this.orchestratorToolsFor(sessionId, m.scopeSessionIds ?? []) : builds ? this.orchestratorToolsFor(sessionId) : (
111765
- // Every other session: its own project (#320, #561)
111766
- this.readsOwnProject(m) ? this.readerToolsFor(sessionId) : void 0
111767
- ),
111768
- 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,
111769
112375
  /*
111770
112376
  * This is **where memory is handed over.** Switching tools breaks externalId, which routes
111771
112377
  * things down this path (a new process), and that is when the past conversation rides along
@@ -111781,7 +112387,7 @@ var SessionManager = class {
111781
112387
  // for a builder session (C-2) even if the app is broken
111782
112388
  m.roleAppend ?? void 0
111783
112389
  ),
111784
- 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,
111785
112391
  // An approved MCP server is a user-folder app — an orchestrator receives those apps right here (M4
111786
112392
  // A-7)
111787
112393
  apps
@@ -111807,6 +112413,8 @@ var SessionManager = class {
111807
112413
  if (!adopt) {
111808
112414
  this.rememberToolPrefix(sessionId);
111809
112415
  this.rememberLaunchSettings(m, cwd);
112416
+ this.backgroundUnknown.delete(sessionId);
112417
+ this.rememberBackgroundTasks(m);
111810
112418
  }
111811
112419
  this.emit({ type: "session_live", sessionId, live: true });
111812
112420
  this.cacheLedger.note(sessionId, this.restartReasons.get(sessionId) ?? "new_process");
@@ -111817,6 +112425,8 @@ var SessionManager = class {
111817
112425
  if (adopt) {
111818
112426
  this.store.upsertSession(m);
111819
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 });
111820
112430
  } else {
111821
112431
  m.state = "idle";
111822
112432
  m.waitingSince = null;
@@ -111862,7 +112472,7 @@ var SessionManager = class {
111862
112472
  * exactly as it was, and only what happens from here on piles up in the copy.
111863
112473
  */
111864
112474
  async forkConversation(sessionId) {
111865
- 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" });
111866
112476
  const m = this.meta.get(sessionId);
111867
112477
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
111868
112478
  const source = m.externalId ?? m.importedFrom;
@@ -111916,7 +112526,10 @@ var SessionManager = class {
111916
112526
  */
111917
112527
  async trashSession(sessionId, removeWorktree = false, removeExternal = false, recovery) {
111918
112528
  const pending = this.trashing.get(sessionId);
111919
- if (pending) return pending;
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
+ }
111920
112533
  const deleting = this.trashSessionOnce(sessionId, removeWorktree, removeExternal, recovery);
111921
112534
  this.trashing.set(sessionId, deleting);
111922
112535
  try {
@@ -111950,6 +112563,13 @@ var SessionManager = class {
111950
112563
  }
111951
112564
  this.readGrants.delete(sessionId);
111952
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);
111953
112573
  for (const ask of [...this.hostAsks.values()]) {
111954
112574
  if (ask.sessionId !== sessionId) continue;
111955
112575
  this.hostAsks.delete(ask.requestId);
@@ -111961,14 +112581,18 @@ var SessionManager = class {
111961
112581
  });
111962
112582
  this.handles.delete(sessionId);
111963
112583
  }
112584
+ this.forgetBackground(sessionId);
111964
112585
  this.closeStream(sessionId);
111965
112586
  let project2;
111966
112587
  if (m.projectId) project2 = this.store.listProjects().find((p) => p.id === m.projectId);
111967
112588
  this.running.delete(sessionId);
111968
112589
  this.restartAfterTurn.delete(sessionId);
112590
+ this.trustRestartNotice.delete(sessionId);
111969
112591
  this.prCheckedAt.delete(sessionId);
111970
112592
  this.appsRestartAfterTurn.delete(sessionId);
111971
- 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);
111972
112596
  this.forgetCommits(sessionId);
111973
112597
  this.meta.delete(sessionId);
111974
112598
  await this.store.trashSession(sessionId, {
@@ -111982,6 +112606,8 @@ var SessionManager = class {
111982
112606
  this.appsHub?.sessionGone(sessionId);
111983
112607
  this.emit({ type: "session_deleted", sessionId });
111984
112608
  }
112609
+ /** The startup repair of recovery folders an interrupted purge left aside (#647) */
112610
+ recoveryRepair = Promise.resolve();
111985
112611
  /** Only one trash operation per session at a time — a purge racing a restore would restore half of a deleted session */
111986
112612
  trashBusy = /* @__PURE__ */ new Set();
111987
112613
  async trashOp(sessionId, run) {
@@ -112046,6 +112672,7 @@ var SessionManager = class {
112046
112672
  */
112047
112673
  async restoreSession(sessionId) {
112048
112674
  return this.trashOp(sessionId, async () => {
112675
+ await this.recoveryRepair;
112049
112676
  const t = this.store.trashedSession(sessionId);
112050
112677
  if (!t) throw Object.assign(new Error(`Not in the trash: ${sessionId}`), { code: "session_not_found" });
112051
112678
  const { record: record2 } = t;
@@ -112102,10 +112729,12 @@ var SessionManager = class {
112102
112729
  * believes 550MB is gone). It is skipped when a live session holds the same conversation (it was pulled back
112103
112730
  * from Past conversations meanwhile): deleting it would take that session's history. Then the worktree, when
112104
112731
  * chosen (a failure does not stop the purge — its folder is plain files the person can still remove). Then our
112105
- * 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.
112106
112734
  */
112107
112735
  async purgeSession(sessionId) {
112108
112736
  return this.trashOp(sessionId, async () => {
112737
+ await this.recoveryRepair;
112109
112738
  const t = this.store.trashedSession(sessionId);
112110
112739
  if (!t) throw Object.assign(new Error(`Not in the trash: ${sessionId}`), { code: "session_not_found" });
112111
112740
  const { session: s, record: record2 } = t;
@@ -112128,8 +112757,16 @@ var SessionManager = class {
112128
112757
  await gitWorktreeRemove(record2.projectPath, s.worktree.path, true).catch(() => {
112129
112758
  });
112130
112759
  }
112131
- await clearWorktreeRecovery(sessionId);
112132
- 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
+ });
112133
112770
  await clearAttachments(sessionId).catch(() => {
112134
112771
  });
112135
112772
  if (record2.projectId) await this.sweepOrphanHandoffNotes(record2.projectId).catch(() => {
@@ -112200,8 +112837,8 @@ var SessionManager = class {
112200
112837
  const m = this.meta.get(sessionId);
112201
112838
  return live && m && live.model !== m.model ? "model_change" : void 0;
112202
112839
  }
112203
- async updateSettings(sessionId, s) {
112204
- 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" });
112205
112842
  const m = this.meta.get(sessionId);
112206
112843
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112207
112844
  if (s.model !== void 0 && s.model !== m.model) this.cacheLedger.note(sessionId, "model_change");
@@ -112214,6 +112851,16 @@ var SessionManager = class {
112214
112851
  if ((s.model !== void 0 || s.effort !== void 0) && m.projectId) {
112215
112852
  this.store.setProjectToolDefaults(m.projectId, m.tool, { model: m.model, effort: m.effort });
112216
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
+ });
112217
112864
  await this.resuming.get(sessionId)?.catch(() => {
112218
112865
  });
112219
112866
  const handle = this.handles.get(sessionId);
@@ -112296,6 +112943,7 @@ var SessionManager = class {
112296
112943
  }
112297
112944
  const wasBusy = inTurn(m.state);
112298
112945
  this.applyStateHint(e, m);
112946
+ if (this.backgroundUnknown.has(m.id)) this.backgroundUnknown.set(m.id, Date.now());
112299
112947
  if (wasBusy && !inTurn(m.state)) {
112300
112948
  endedTurn = true;
112301
112949
  const ref = this.builderRefOf(m);
@@ -112313,6 +112961,7 @@ var SessionManager = class {
112313
112961
  });
112314
112962
  this.handles.delete(e.sessionId);
112315
112963
  this.running.delete(e.sessionId);
112964
+ this.emit({ type: "session_live", sessionId: e.sessionId, live: false, reason: "crashed" });
112316
112965
  }
112317
112966
  }
112318
112967
  }
@@ -112322,13 +112971,37 @@ var SessionManager = class {
112322
112971
  this.emit(seq != null ? { ...e, seq } : e);
112323
112972
  if (endedTurn && e.sessionId && !run?.tracksInputs) void this.reportBackIfAwaited(e.sessionId, e, run?.completedAnswer);
112324
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);
112325
112975
  if (endedTurn && e.sessionId) this.forgetCommits(e.sessionId);
112326
112976
  if (endedTurn && e.sessionId) this.applyDeferredRestart(e.sessionId);
112977
+ if (e.type === "background_tasks" && e.sessionId) this.applyDeferredRestart(e.sessionId);
112327
112978
  }
112328
- /** 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
+ */
112329
112994
  applyDeferredRestart(sessionId) {
112330
112995
  const session2 = this.meta.get(sessionId);
112331
- 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);
112332
113005
  const apps = this.appsRestartAfterTurn.delete(sessionId);
112333
113006
  const definitions = this.definitionsRestartAfterTurn.delete(sessionId);
112334
113007
  const launchSettings = this.launchSettingsRestartAfterTurn.delete(sessionId);
@@ -112342,6 +113015,28 @@ var SessionManager = class {
112342
113015
  void restarting.catch(() => {
112343
113016
  });
112344
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
+ }
112345
113040
  /**
112346
113041
  * Notifies the orchestrator when the work it assigned finishes — **the other half of "one window".**
112347
113042
  *
@@ -112356,8 +113051,8 @@ var SessionManager = class {
112356
113051
  */
112357
113052
  async reportBackIfAwaited(sessionId, ending, answer) {
112358
113053
  const orchestratorId = this.awaitingReport.get(sessionId);
112359
- if (!orchestratorId) return;
112360
- this.awaitingReport.delete(sessionId);
113054
+ if (!orchestratorId || this.handingOver) return;
113055
+ this.clearReport(sessionId);
112361
113056
  const target = this.meta.get(sessionId);
112362
113057
  if (!target || !this.meta.has(orchestratorId)) return;
112363
113058
  const project2 = target.projectId ? this.store.listProjects().find((p) => p.id === target.projectId)?.name ?? "(project no longer exists)" : "(none)";
@@ -112462,6 +113157,8 @@ If you need more, you can read that session's recent conversation with read_sess
112462
113157
  break;
112463
113158
  case "background_tasks":
112464
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);
112465
113162
  break;
112466
113163
  case "agent_version":
112467
113164
  m.agentVersion = e.version;
@@ -112700,7 +113397,7 @@ If you need more, you can read that session's recent conversation with read_sess
112700
113397
  async deliver(sessionId, text2, attachments, from, relayed, fromApp, fromAppVia = "inline", sendInput) {
112701
113398
  const restarting = this.deferredRestarts.get(sessionId);
112702
113399
  if (restarting) await restarting;
112703
- 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" });
112704
113401
  const m = this.meta.get(sessionId);
112705
113402
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112706
113403
  if (!this.handles.has(sessionId)) {
@@ -112711,7 +113408,7 @@ If you need more, you can read that session's recent conversation with read_sess
112711
113408
  });
112712
113409
  }
112713
113410
  }
112714
- 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" });
112715
113412
  const h = this.requireHandle(sessionId);
112716
113413
  this.closeStream(sessionId);
112717
113414
  const seq = this.store.nextSeq(sessionId);
@@ -112729,7 +113426,7 @@ If you need more, you can read that session's recent conversation with read_sess
112729
113426
  ]);
112730
113427
  m.lastSeq = seq;
112731
113428
  m.lastReadSeq = seq;
112732
- if (m.autoNamed && m.name === "New session" && !fromApp) {
113429
+ if (m.autoNamed && m.name === "New session" && !fromApp && !from) {
112733
113430
  m.name = truncate(text2);
112734
113431
  this.emit({ type: "session_title", sessionId, title: m.name, auto: true });
112735
113432
  }
@@ -112766,7 +113463,7 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112766
113463
  * untouched, and only the tool underneath it is swapped.
112767
113464
  */
112768
113465
  async switchTool(sessionId, tool) {
112769
- 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" });
112770
113467
  const m = this.meta.get(sessionId);
112771
113468
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
112772
113469
  if (m.tool === tool) return { ...m, live: this.handles.has(sessionId) };
@@ -112803,8 +113500,9 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112803
113500
  const ask = this.hostAsks.get(requestId);
112804
113501
  if (ask && ask.sessionId === sessionId) {
112805
113502
  this.hostAsks.delete(requestId);
112806
- 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);
112807
113504
  const answer = !keepsAlways && decision === "always" ? "allow" : decision;
113505
+ this.restAfterCard(ask);
112808
113506
  this.onEvent({ type: "approval_resolved", sessionId, requestId, decision: answer });
112809
113507
  ask.resolve(ask.detail.kind === "agent_start" && answer === "always" && scope === "project" ? "always_project" : answer);
112810
113508
  const after = this.meta.get(sessionId);
@@ -112980,6 +113678,37 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112980
113678
  * opens would make that click feel slow.
112981
113679
  */
112982
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
+ }
112983
113712
  async listModels(tool) {
112984
113713
  const adapter = this.adapters.get(tool);
112985
113714
  if (!adapter?.listModels) {
@@ -112988,13 +113717,40 @@ ${attachments.map((a) => `@${a.path}`).join("\n")}` : text2;
112988
113717
  const hit = this.modelCache.get(tool);
112989
113718
  if (hit && Date.now() - hit.at < 5 * 6e4) return { supported: true, models: hit.models };
112990
113719
  try {
112991
- const models = await withTimeout(adapter.listModels(), 15e3);
112992
- this.modelCache.set(tool, { models, at: Date.now() });
113720
+ const models = await withTimeout(this.fetchModels(tool, adapter), 15e3);
112993
113721
  return { supported: true, models };
112994
113722
  } catch (err) {
112995
113723
  return { supported: false, reason: err.message, models: hit?.models ?? [] };
112996
113724
  }
112997
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
+ }
112998
113754
  /** A project's working directory. Used by the terminal to decide its own key (cwd) */
112999
113755
  cwdOfProject(projectId) {
113000
113756
  return this.cwdOf(projectId);
@@ -113431,13 +114187,14 @@ ${String(stderr).slice(0, 2e3)}`
113431
114187
  * reports background work.
113432
114188
  */
113433
114189
  sessionIdle(sessionId) {
113434
- if (this.worktreeCleanup.has(sessionId)) return { idle: false, reason: "turn" };
114190
+ if (this.worktreeCleanupBlocks(sessionId)) return { idle: false, reason: "turn" };
113435
114191
  const m = this.meta.get(sessionId);
113436
114192
  if (!m) throw Object.assign(new Error(`Session not found: ${sessionId}`), { code: "session_not_found" });
113437
- if (this.agentRuns.get(sessionId)?.waitingForInputs) return { idle: false, reason: "turn" };
114193
+ if (this.inputsHeld(sessionId)) return { idle: false, reason: "turn" };
113438
114194
  return sessionIdle(m, {
113439
114195
  running: this.handles.has(sessionId),
113440
- 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)
113441
114198
  });
113442
114199
  }
113443
114200
  /**
@@ -113497,7 +114254,7 @@ ${String(stderr).slice(0, 2e3)}`
113497
114254
  * process.
113498
114255
  */
113499
114256
  async restartSession(sessionId, cause2) {
113500
- 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" });
113501
114258
  if (cause2) {
113502
114259
  this.restartReasons.set(sessionId, cause2);
113503
114260
  if (cause2 === "launch_settings") console.error(`[sessions] restarting ${sessionId}: launch settings changed or previously unrecorded`);
@@ -113506,8 +114263,11 @@ ${String(stderr).slice(0, 2e3)}`
113506
114263
  this.appsRestartAfterTurn.delete(sessionId);
113507
114264
  this.definitionsRestartAfterTurn.delete(sessionId);
113508
114265
  this.launchSettingsRestartAfterTurn.delete(sessionId);
114266
+ this.noticeTrustRestart(sessionId);
114267
+ this.clearBackgroundHold(sessionId);
113509
114268
  const h = this.handles.get(sessionId);
113510
114269
  if (h) {
114270
+ this.noticeBackgroundStopped(sessionId, cause2);
113511
114271
  await h.dispose().catch(() => {
113512
114272
  });
113513
114273
  this.stampExternalSynced(sessionId);
@@ -113515,7 +114275,17 @@ ${String(stderr).slice(0, 2e3)}`
113515
114275
  this.running.delete(sessionId);
113516
114276
  this.closeStream(sessionId);
113517
114277
  }
113518
- 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;
113519
114289
  }
113520
114290
  /** The persisted prefix a process was started with. It is separate from SessionInfo: a screen never needs it. */
113521
114291
  toolPrefixKey(sessionId) {
@@ -113587,8 +114357,9 @@ ${String(stderr).slice(0, 2e3)}`
113587
114357
  * environment). The adapter owns the fingerprint; the host only gives it the same safe
113588
114358
  * boundary behaviour as changed tool definitions.
113589
114359
  */
113590
- async refreshLaunchSettings() {
113591
- 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;
113592
114363
  const cwd = this.store.sessionCwd(session2.id);
113593
114364
  if (!cwd) continue;
113594
114365
  const next = this.launchSettingsFingerprint(session2, cwd);
@@ -113597,6 +114368,7 @@ ${String(stderr).slice(0, 2e3)}`
113597
114368
  this.store.setAppSetting(this.launchSettingsKey(session2.id), next);
113598
114369
  continue;
113599
114370
  }
114371
+ if (trustChange) this.trustRestartNotice.add(session2.id);
113600
114372
  if (inTurn(session2.state)) {
113601
114373
  this.restartAfterTurn.add(session2.id);
113602
114374
  this.launchSettingsRestartAfterTurn.add(session2.id);
@@ -113621,7 +114393,7 @@ ${String(stderr).slice(0, 2e3)}`
113621
114393
  const off = () => !this.uiPreferences().sessionTools;
113622
114394
  const offError = "Turned off in Settings";
113623
114395
  return {
113624
- 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),
113625
114397
  listSessions: async () => off() ? [] : view2.listSessions(),
113626
114398
  readSession: async (...a) => off() ? { ok: false, error: offError } : view2.readSession(...a),
113627
114399
  recall: async (...a) => off() ? { hits: [] } : view2.recall(...a),
@@ -113667,7 +114439,7 @@ ${String(stderr).slice(0, 2e3)}`
113667
114439
  );
113668
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}`;
113669
114441
  return {
113670
- updateCentralu: async (action) => this.updateCentraluAsked(orchestratorId, action),
114442
+ updateCentralu: async (action, signal) => this.updateCentraluAsked(orchestratorId, action, signal),
113671
114443
  listSessions: async () => {
113672
114444
  const byId = projects();
113673
114445
  const mine = [...this.meta.values()].filter((s) => s.id !== orchestratorId && inScope(s)).map((s) => ({
@@ -113700,15 +114472,7 @@ ${String(stderr).slice(0, 2e3)}`
113700
114472
  const target = this.meta.get(sessionId);
113701
114473
  if (!target || !inScope(target)) return { ok: false, error: scopeError(sessionId) };
113702
114474
  try {
113703
- const info = await this.updateSettings(sessionId, s);
113704
- this.emit({
113705
- type: "settings_changed",
113706
- sessionId,
113707
- model: info.model,
113708
- effort: info.effort,
113709
- verbosity: info.verbosity,
113710
- serviceTier: info.serviceTier
113711
- });
114475
+ const info = await this.updateSettings(sessionId, s, "orchestrator");
113712
114476
  return { ok: true, ...info.applied === "after_turn" ? { deferred: true } : {} };
113713
114477
  } catch (e) {
113714
114478
  return { ok: false, error: e.message };
@@ -113864,8 +114628,8 @@ ${String(stderr).slice(0, 2e3)}`
113864
114628
  sessionId: orchestratorId,
113865
114629
  name: orch?.name ?? "Orchestrator"
113866
114630
  });
113867
- if (reportBack) this.awaitingReport.set(sessionId, orchestratorId);
113868
- 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);
113869
114633
  return { ok: true };
113870
114634
  } catch (e) {
113871
114635
  return { ok: false, error: e.message };
@@ -114130,15 +114894,19 @@ ${String(stderr).slice(0, 2e3)}`
114130
114894
  if (boundary) console.error(`[permissions] auto session ${sessionId}: ${boundary} allowed`);
114131
114895
  return true;
114132
114896
  }
114133
- /** Auto allows and logs; other presets raise a host card, withdrawn if the signal fires. */
114134
- 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 = {}) {
114135
114903
  if (signal?.aborted) return Promise.resolve(null);
114136
114904
  const note = detail.kind === "project_access" && !detail.to.trusted ? " (target not trusted)" : "";
114137
114905
  if (this.autoBoundaryOpen(sessionId, `${detail.kind}${note}`)) return Promise.resolve("allow");
114138
114906
  return new Promise((resolve12) => {
114139
114907
  if (signal?.aborted) return resolve12(null);
114140
- const requestId = `${detail.kind === "project_access" ? "xp" : detail.kind === "agent_start" ? "ag" : detail.kind === "centralu_update" ? "up" : "mk"}-${randomUUID10()}`;
114141
- 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 });
114142
114910
  signal?.addEventListener("abort", () => {
114143
114911
  if (!this.hostAsks.delete(requestId)) return;
114144
114912
  const m = this.meta.get(sessionId);
@@ -114335,17 +115103,20 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114335
115103
  this.delegations.set(key, d2);
114336
115104
  return d2;
114337
115105
  }
114338
- /** Read an existing turn without claiming it in delegations, agentRuns or the caller's sleep graph. */
114339
- observeTurn(child, subscribe) {
114340
- 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);
114341
115112
  const shared = active && !active.finished ? active : void 0;
114342
- const wait = shared ?? new AgentRunWait(
114343
- () => {
114344
- },
114345
- () => child.name,
114346
- void 0,
114347
- () => finalAnswer(this.store.loadMessages(child.id, 50))
114348
- );
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)));
114349
115120
  const project2 = this.store.listProjects().find((p) => p.id === child.projectId);
114350
115121
  const target = { id: child.projectId ?? "", name: project2?.name ?? "", path: this.store.sessionCwd(child.id) ?? project2?.path ?? "" };
114351
115122
  if (!shared && subscribe) {
@@ -114443,7 +115214,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114443
115214
  * 1. Who may: an ordinary session of a trusted project (the rule ask_project and attach_app follow, #382), and
114444
115215
  * not one that was itself started or asked by another session (`askedBy`): depth one.
114445
115216
  * 2. How many: at most `AGENT_CEILING` children run at once in a project, over all its sessions.
114446
- * 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.
114447
115219
  * 4. The child: a visible session in the caller's folder, marked `askedBy` the caller, on the tool, model and
114448
115220
  * effort asked for (or the project's own, as a session the person opens there), under **the caller's own
114449
115221
  * permission preset**: never more than the caller has, and nothing the caller can change afterwards.
@@ -114452,49 +115224,184 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114452
115224
  * "working" and the caller is told when the turn ends (the reportBack path); agent_session waits again.
114453
115225
  */
114454
115226
  async startAgent(callerId, opts, signal) {
114455
- const caller = this.meta.get(callerId);
114456
- if (!caller?.projectId) return { ok: false, error: "This session belongs to no project, so it cannot start agents." };
114457
- if ((caller.askedBy || [...this.delegations.values()].some((d8) => d8.sessionId === callerId && !d8.agent && !d8.settled)) && !this.autoBoundaryOpen(callerId, "delegation depth")) {
114458
- 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);
114459
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;
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 });
114460
115277
  const project2 = this.store.listProjects().find((p) => p.id === caller.projectId);
114461
- 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." });
114462
115279
  if (project2.trusted !== true && !this.autoBoundaryOpen(callerId, "start in an untrusted project")) {
114463
- 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}` });
114464
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));
114465
115286
  const tool = opts.tool ?? this.firstTool(project2.defaultTool);
114466
- 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." });
114467
115288
  const adapter = this.adapters.get(tool);
114468
- 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"}.` });
114469
115290
  const full = this.agentCeilingError(callerId, project2.id);
114470
- if (full) return { ok: false, error: full };
115291
+ if (full) return done({ ok: false, error: full });
114471
115292
  const remembered = this.store.projectToolDefaults(project2.id)[tool];
114472
115293
  const model = opts.model ?? remembered?.model ?? void 0;
114473
115294
  const effort = opts.effort ?? (!opts.model || opts.model === remembered?.model ? remembered?.effort ?? void 0 : void 0);
114474
- const wrong = await this.agentChoiceError(tool, opts.model, opts.effort, model);
114475
- 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;
114476
115299
  const found = await adapter.detect();
114477
- 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}` });
114478
115301
  const worktree = opts.worktree || opts.branch ? await this.agentWorktreeBranch(project2, opts) : null;
114479
- if (worktree && "error" in worktree) return { ok: false, error: worktree.error };
114480
- const consent = await this.ensureAgentConsent(
114481
- callerId,
114482
- {
114483
- kind: "agent_start",
114484
- project: { id: project2.id, name: project2.name },
114485
- tool: adapter.descriptor.label,
114486
- ...model ? { model } : {},
114487
- preset: caller.permissionPreset,
114488
- ...worktree ? { worktree: { branch: worktree.branch } } : {},
114489
- text: taskLine(opts.task)
114490
- },
114491
- signal
114492
- );
114493
- 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;
114494
115381
  const fullNow = this.agentCeilingError(callerId, project2.id);
114495
- 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;
114496
115402
  const cwd = worktree ? project2.path : this.store.sessionCwd(callerId) ?? caller.worktree?.path ?? project2.path;
114497
115403
  let child;
115404
+ progress.stage = "creating";
114498
115405
  try {
114499
115406
  child = await this.createSession({
114500
115407
  projectId: project2.id,
@@ -114512,21 +115419,24 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114512
115419
  ...worktree ? { worktree: true, worktreeBranch: worktree.branch, ...caller.worktree ? { worktreeBase: caller.worktree.branch } : {} } : {}
114513
115420
  });
114514
115421
  } catch (e) {
114515
- return { ok: false, error: `Could not start the session: ${e.message}` };
115422
+ return done({ ok: false, error: `Could not start the session: ${e.message}` });
114516
115423
  }
114517
- 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));
114518
115427
  const key = `${callerId}>${child.id}`;
114519
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;
114520
115431
  try {
114521
- 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);
114522
115433
  } catch (e) {
114523
115434
  this.delegations.delete(key);
114524
115435
  if (this.agentRuns.get(child.id) === d2.wait) this.agentRuns.delete(child.id);
114525
- 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}` });
114526
115437
  }
114527
115438
  const notice = this.handles.get(child.id)?.delegationNotice?.();
114528
- const result = opts.wait === false ? this.agentWorking(d2) : await this.awaitAgent(key, d2, signal);
114529
- 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 };
114530
115440
  }
114531
115441
  /**
114532
115442
  * agent_session (#561): safe/normal work only with their own children; auto opens ownership within the project.
@@ -114542,6 +115452,17 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114542
115452
  if (opts.sessionId === callerId && opts.action !== "status") {
114543
115453
  return { ok: false, error: "agent_session cannot wait on, stop, delete or send to its own calling session." };
114544
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
+ }
114545
115466
  const caller = this.meta.get(callerId);
114546
115467
  const child = this.meta.get(opts.sessionId);
114547
115468
  const deletingWithoutWorktree = opts.action === "delete" && !child?.worktree;
@@ -114551,6 +115472,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114551
115472
  }
114552
115473
  if (opts.sessionId === callerId) return this.agentAtRest(child);
114553
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);
114554
115477
  let d2 = this.delegations.get(key);
114555
115478
  let releaseObservation;
114556
115479
  if (!d2 && this.autoBoundaryOpen(callerId)) {
@@ -114572,7 +115495,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114572
115495
  return d2 ? await this.awaitAgent(key, d2, signal, observing) : this.agentAtRest(child);
114573
115496
  case "stop": {
114574
115497
  if (!d2 || d2.settled) return { ok: false, error: `Nothing you gave "${child.name}" is running.` };
114575
- 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);
114576
115499
  d2.stop("the session that started it stopped it", observing);
114577
115500
  await d2.outcome;
114578
115501
  if (!observing) this.delegations.delete(key);
@@ -114607,7 +115530,12 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114607
115530
  if (!this.adapters.get(child.tool)?.capabilities.midTurnInput || !this.handles.get(child.id)?.sendTracked) {
114608
115531
  return { ok: false, error: `"${child.name}" cannot take messages while working. Wait for it (action "wait") or stop it first.` };
114609
115532
  }
114610
- 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();
114611
115539
  const release = () => {
114612
115540
  releaseHold();
114613
115541
  this.applyDeferredRestart(child.id);
@@ -114615,12 +115543,24 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114615
115543
  try {
114616
115544
  await this.deliver(child.id, text2, void 0, from, false, void 0, "inline", (h, input) => {
114617
115545
  const fail4 = (e) => {
114618
- 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}`));
114619
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
+ }
114620
115556
  };
114621
115557
  void h.sendTracked(input, { complete: release, fail: fail4 }).then(release, fail4);
114622
115558
  });
114623
115559
  } catch (e) {
115560
+ if (record2) {
115561
+ wait.fail(e);
115562
+ if (this.observerNotes.get(key) === record2) this.observerNotes.delete(key);
115563
+ }
114624
115564
  release();
114625
115565
  return { ok: false, error: `Could not send it: ${e.message}` };
114626
115566
  }
@@ -114651,6 +115591,44 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114651
115591
  releaseObservation?.();
114652
115592
  }
114653
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
+ }
114654
115632
  /**
114655
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
114656
115634
  * named from the job (`agentBranch`). Refused before the card when the project is no git repository, so the person
@@ -114663,8 +115641,22 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114663
115641
  if (asked) return await gitValidBranchName(asked) ? { branch: asked } : { error: `Not a valid branch name: ${asked}` };
114664
115642
  return { branch: agentBranch(opts.task, randomUUID10()) };
114665
115643
  }
114666
- /** Prevent a send/resume from racing the snapshot and worktree removal. */
114667
- 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
+ }
114668
115660
  /** #608: the parent decides; local work follows the session into the trash in every preset. */
114669
115661
  async cleanUpWorktreeChild(target) {
114670
115662
  if (!target.worktree?.base || !target.projectId) {
@@ -114678,8 +115670,16 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114678
115670
  }
114679
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` };
114680
115672
  if (this.worktreeCleanup.has(target.id)) return { ok: false, error: "This worktree is already being removed" };
114681
- 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;
114682
115681
  const { branch, path: path2 } = target.worktree;
115682
+ this.worktreeCleanup.set(target.id, canonicalPath(path2));
114683
115683
  try {
114684
115684
  const cwd = this.cwdOf(target.projectId);
114685
115685
  const handle = this.handles.get(target.id);
@@ -114700,7 +115700,11 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114700
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.`
114701
115701
  });
114702
115702
  }
115703
+ const appeared = occupied();
115704
+ if (appeared) return appeared;
114703
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)}.` };
114704
115708
  try {
114705
115709
  await gitWorktreeRemove(cwd, path2, true, true);
114706
115710
  } catch (e) {
@@ -114708,6 +115712,8 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114708
115712
  }
114709
115713
  try {
114710
115714
  await gitBranchDelete(cwd, branch, this.gitTrust(target.projectId));
115715
+ await clearRecoveredBranchMark(cwd, branch).catch(() => {
115716
+ });
114711
115717
  } catch (e) {
114712
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)}.` };
114713
115719
  }
@@ -114718,12 +115724,27 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114718
115724
  this.worktreeCleanup.delete(target.id);
114719
115725
  }
114720
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
+ }
114721
115742
  /** A saved ceiling overrides the caller's preset default: auto unlimited, otherwise AGENT_CEILING. */
114722
115743
  agentCeilingError(callerId, projectId) {
114723
115744
  const configured = this.uiPreferences().agentCeiling;
114724
115745
  const ceiling = configured ?? (this.autoBoundaryOpen(callerId) ? 0 : AGENT_CEILING);
114725
115746
  if (ceiling === 0) return null;
114726
- 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);
114727
115748
  if (running < ceiling) return null;
114728
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.`;
114729
115750
  }
@@ -114733,22 +115754,38 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114733
115754
  * list's ids or labels contain (an alias such as "sonnet") passes, and a tool that cannot list its models passes all.
114734
115755
  */
114735
115756
  async agentChoiceError(tool, askedModel, askedEffort, model) {
114736
- if (!askedModel && !askedEffort) return null;
114737
- const { supported, models } = await this.listModels(tool);
114738
- if (!supported || models.length === 0) return null;
114739
- const find = (name) => {
114740
- const n = name.toLowerCase();
114741
- return models.find((m) => m.id.toLowerCase() === n) ?? models.find((m) => m.id.toLowerCase().includes(n) || m.label.toLowerCase().includes(n));
114742
- };
114743
- if (askedModel && !find(askedModel)) return `${tool} has no model "${askedModel}". Its models: ${models.map((m) => m.id).join(", ")}.`;
114744
- if (askedEffort) {
114745
- const m = model ? find(model) : models[0];
114746
- 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);
114747
115783
  }
114748
- return null;
115784
+ return { error: wrong };
114749
115785
  }
114750
115786
  /** Update grants last only for the calling session on this host; they never change automatic application. */
114751
- async updateCentraluAsked(sessionId, action) {
115787
+ async updateCentraluAsked(sessionId, action, signal) {
115788
+ if (action === "apply") return this.applyUpdateAsked(sessionId, signal);
114752
115789
  const updates2 = this.updates;
114753
115790
  if (!updates2) return { ok: false, error: "Update checks are unavailable" };
114754
115791
  if (this.toolProfileOf(sessionId) === "reader" && !this.updateConsents.has(sessionId)) {
@@ -114756,19 +115793,71 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114756
115793
  if (decision === "always") this.updateConsents.add(sessionId);
114757
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." };
114758
115795
  }
114759
- 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 } : {} };
114760
115829
  }
114761
115830
  /**
114762
115831
  * The person's consent for a session to start agents (#561), the first time it does: a card in the caller's approval
114763
115832
  * slot. "Always" is kept for this session while this host serves it; with ⌥ (scope `project`) it is remembered as the
114764
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.
114765
115839
  */
114766
- async ensureAgentConsent(callerId, detail, signal) {
115840
+ async ensureAgentConsent(callerId, detail, signal, onCard) {
114767
115841
  if (this.agentConsents.has(callerId)) return { ok: true };
114768
115842
  if (this.store.getProjectConsent(detail.project.id, detail.project.id, "agents")) return { ok: true };
114769
- 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) {
114770
115859
  if (decision === "always_project") {
114771
- this.store.setProjectConsent(detail.project.id, detail.project.id, "agents");
115860
+ this.store.setProjectConsent(projectId, projectId, "agents");
114772
115861
  this.emit({ type: "project_consents_changed" });
114773
115862
  return { ok: true };
114774
115863
  }
@@ -114777,15 +115866,112 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114777
115866
  return { ok: true };
114778
115867
  }
114779
115868
  if (decision === "allow") return { ok: true };
114780
- 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." };
114781
- 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
+ })();
114782
115968
  }
114783
115969
  /**
114784
115970
  * "Working": the caller is told when the turn ends (#561), through the report the orchestrator's reportBack uses, unless
114785
115971
  * a call is waiting on it by then (`awaitAgent` takes the marker down while it waits)
114786
115972
  */
114787
115973
  agentWorking(d2, arm = true, observing = false) {
114788
- if (arm && !observing) this.awaitingReport.set(d2.sessionId, d2.callerId);
115974
+ if (arm && !observing) this.markReport(d2.sessionId, d2.callerId);
114789
115975
  const notice = d2.notice();
114790
115976
  return {
114791
115977
  ok: true,
@@ -114797,6 +115983,43 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114797
115983
  ...notice ? { notice } : {}
114798
115984
  };
114799
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
+ }
114800
116023
  /** A child with nothing of the caller's running: its last final answer (#561) */
114801
116024
  agentAtRest(child) {
114802
116025
  return {
@@ -114810,12 +116033,12 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114810
116033
  };
114811
116034
  }
114812
116035
  /** Waits on a child's turn up to the bound (#561), like `awaitDelegation`, and reads its outcome for the caller */
114813
- async awaitAgent(key, d2, signal, observing = false) {
114814
- 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);
114815
116038
  const name = () => this.meta.get(d2.sessionId)?.name ?? d2.sessionId;
114816
116039
  let timer;
114817
116040
  let onAbort;
114818
- const bound = new Promise((resolve12) => timer = setTimeout(() => resolve12("bound"), ASK_WAIT_MS));
116041
+ const bound = new Promise((resolve12) => timer = setTimeout(() => resolve12("bound"), boundMs));
114819
116042
  const aborted2 = new Promise((resolve12) => {
114820
116043
  if (!signal) return;
114821
116044
  onAbort = () => resolve12("aborted");
@@ -114918,6 +116141,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114918
116141
  const next = [...this.hostAsks.values()].find((a) => a.sessionId === sessionId);
114919
116142
  if (!next) return;
114920
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;
114921
116145
  if (!next.shown) {
114922
116146
  next.shown = true;
114923
116147
  this.onEvent(e);
@@ -114927,6 +116151,19 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
114927
116151
  this.store.upsertSession(m);
114928
116152
  this.emit(e);
114929
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
+ }
114930
116167
  /** Screen-originated capability questions still awaiting an answer (M4 D-4) */
114931
116168
  appQuestionList() {
114932
116169
  return [...this.appQuestions.values()].map((x) => ({ ...x.question })).sort((a, b2) => a.askedAt - b2.askedAt);
@@ -115169,8 +116406,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115169
116406
  if (!profileAllows(profile, name)) {
115170
116407
  throw Object.assign(new Error(`Not a tool of this session: ${name}`), { code: "internal" });
115171
116408
  }
115172
- const tools = profile === "scoped" ? this.orchestratorToolsFor(sessionId, m.scopeSessionIds ?? []) : profile === "reader" ? this.readerToolsFor(sessionId) : this.orchestratorToolsFor(sessionId);
115173
- return runOrchestratorTool(tools, name, args, { sessionId, profile });
116409
+ return runOrchestratorTool(this.toolBundleOf(sessionId, profile, m), name, args, { sessionId, profile });
115174
116410
  }
115175
116411
  /**
115176
116412
  * Receives the external app runtime (M4 A-5) — a session that comes up after this receives whatever
@@ -115188,6 +116424,10 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115188
116424
  useUpdates(updates2) {
115189
116425
  this.updates = updates2;
115190
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
+ }
115191
116431
  /**
115192
116432
  * Says in the conversation that the session moved to a newer CLI (#297): restarting a process by itself is never
115193
116433
  * silent. A notice from Centralu, not from the tool, so it reads as what happened rather than as something to fix.
@@ -115332,41 +116572,33 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115332
116572
  if (this.isAppAgentSession(m)) return void 0;
115333
116573
  return this.appsHub?.attach({ id: m.id, kind: m.kind, projectId: m.projectId, builderOf: this.builderRefOf(m) }) ?? void 0;
115334
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
+ }
115335
116579
  /**
115336
- * Is this an agent session stood up at an external app's request (M4 D-1) — one of the workers an
115337
- * external app owns (`appId`) that is not that app's own builder session. No separate marker exists
115338
- * for this: ownership (`appId`) and the builder directory already say it. A row stamped with a
115339
- * 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.
115340
116583
  */
115341
- isAppAgentSession(m) {
115342
- 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 };
115343
116586
  }
115344
116587
  /**
115345
- * The tool bundle this session receives (#69). null means no tools.
115346
- * Whether something is a manager comes down to a single relationship: if it has a worktree child, it
115347
- * is a manager (including an archived child — tools are not dangerous, and a manager created through
115348
- * 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.
115349
116591
  */
115350
116592
  toolProfileOf(sessionId) {
115351
116593
  const m = this.meta.get(sessionId);
115352
- if (!m) return null;
115353
- if (m.kind === "orchestrator") return "orchestrator";
115354
- if (m.kind === "coordinator") return "scoped";
115355
- if (this.builderRefOf(m)) return "builder";
115356
- return this.readsOwnProject(m) ? "reader" : null;
116594
+ return m ? toolProfileFor(this.profileFacts(m)) : null;
115357
116595
  }
115358
- /**
115359
- * Does this ordinary session get the light, read-only set (#320)? On unless the person turned it off
115360
- * in Settings, and never for an agent session an app stood up (M4 D-1): that agent hands its answer
115361
- * back to the app, so reading the project's conversations would carry them out to an app that was
115362
- * never given them (the same reason it gets no apps, appsFor).
115363
- *
115364
- * The schemas are attached when the session starts, like every other bundle; turning the setting off
115365
- * stops the calls at once (readerToolsFor and the bridge both ask again) and takes the schemas away
115366
- * the next time the session starts.
115367
- */
115368
- readsOwnProject(m) {
115369
- 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
+ });
115370
116602
  }
115371
116603
  /**
115372
116604
  * A profile that directs other sessions — every profile but the reader. The reader set (#320)
@@ -115610,6 +116842,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115610
116842
  );
115611
116843
  }
115612
116844
  async disposeAll() {
116845
+ this.disposing = true;
115613
116846
  this.watchers.close();
115614
116847
  for (const readers of this.turnObservers.values()) for (const observer of readers) observer.fail(new Error("Centralu is stopping"));
115615
116848
  this.appsHub?.rt.attachBrokerHost(null);
@@ -115641,6 +116874,7 @@ ${list()}` : `${target.name} has no ordinary session. The person has to open one
115641
116874
  async detachAll() {
115642
116875
  this.watchers.close();
115643
116876
  this.appsHub?.rt.attachBrokerHost(null);
116877
+ this.handingOver = true;
115644
116878
  for (const run of [...this.agentRuns.values()]) run.fail(new Error("Centralu is restarting"));
115645
116879
  for (const readers of this.turnObservers.values()) for (const observer of readers) observer.fail(new Error("Centralu is restarting"));
115646
116880
  for (const ask of [...this.hostAsks.values()]) ask.resolve(null);
@@ -115674,7 +116908,7 @@ import { relative as relative9 } from "node:path";
115674
116908
  // packages/agent-host/src/themes.ts
115675
116909
  import { randomBytes as randomBytes4 } from "node:crypto";
115676
116910
  import { watch as watch2 } from "node:fs";
115677
- 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";
115678
116912
  import { basename as basename6, join as join30 } from "node:path";
115679
116913
  var MAX_FILE_BYTES = 256 * 1024;
115680
116914
  var SETTLE_MS = 100;
@@ -115788,7 +117022,7 @@ var ThemeFiles = class {
115788
117022
  const tmp = `${path2}.${randomBytes4(6).toString("hex")}.tmp`;
115789
117023
  try {
115790
117024
  await writeFile5(tmp, text2, "utf8");
115791
- await rename4(tmp, path2);
117025
+ await rename5(tmp, path2);
115792
117026
  } catch (e) {
115793
117027
  await unlink2(tmp).catch(() => {
115794
117028
  });
@@ -115799,7 +117033,7 @@ var ThemeFiles = class {
115799
117033
 
115800
117034
  // packages/agent-host/src/dev-services/strays.ts
115801
117035
  import { execFile as execFile7 } from "node:child_process";
115802
- import { realpathSync as realpathSync8 } from "node:fs";
117036
+ import { realpathSync as realpathSync9 } from "node:fs";
115803
117037
  import { promisify as promisify7 } from "node:util";
115804
117038
  import { userInfo } from "node:os";
115805
117039
  var exec7 = promisify7(execFile7);
@@ -115910,7 +117144,7 @@ function resolveRoots(roots) {
115910
117144
  for (const r of roots) {
115911
117145
  out.add(r);
115912
117146
  try {
115913
- out.add(realpathSync8(r));
117147
+ out.add(realpathSync9(r));
115914
117148
  } catch {
115915
117149
  }
115916
117150
  }
@@ -116023,7 +117257,7 @@ var toInfo = (h) => ({
116023
117257
  function sessionIdsOf(panels) {
116024
117258
  return panels.flatMap((p) => p.kind === "session" ? [p.sessionId] : []);
116025
117259
  }
116026
- 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 } = {}) {
116027
117261
  const requireThemes = () => {
116028
117262
  if (!themes2) throw Object.assign(new Error("Theme files are unavailable"), { code: "internal" });
116029
117263
  return themes2;
@@ -116136,8 +117370,8 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
116136
117370
  "agents.resumeSession": async (p) => mgr2.resumeSession(RpcMethods["agents.resumeSession"].params.parse(p).sessionId),
116137
117371
  "agents.forkConversation": async (p) => mgr2.forkConversation(RpcMethods["agents.forkConversation"].params.parse(p).sessionId),
116138
117372
  "agents.updateSettings": async (p) => {
116139
- const { sessionId, ...settings } = RpcMethods["agents.updateSettings"].params.parse(p);
116140
- 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");
116141
117375
  },
116142
117376
  "agents.switchTool": async (p) => {
116143
117377
  const { sessionId, tool } = RpcMethods["agents.switchTool"].params.parse(p);
@@ -116497,7 +117731,7 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
116497
117731
  },
116498
117732
  "projects.setTrusted": async (p) => {
116499
117733
  const { projectId, trusted } = RpcMethods["projects.setTrusted"].params.parse(p);
116500
- mgr2.setProjectTrusted(projectId, trusted);
117734
+ void mgr2.setProjectTrusted(projectId, trusted).catch((err) => console.error("[sessions] restarting for a trust change failed:", err));
116501
117735
  externalApps2?.refresh();
116502
117736
  return { ok: true };
116503
117737
  },
@@ -116685,6 +117919,16 @@ function createRpcHandler(mgr2, adapters2, { terminals: terminals2, updates: upd
116685
117919
  // Answers once the install has started, not once it has finished — see the note on
116686
117920
  // `updates.apply` in the protocol. The rest arrives as `update_status` events.
116687
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
+ },
116688
117932
  "host.stop": async () => {
116689
117933
  if (!stopHost) {
116690
117934
  throw Object.assign(new Error("This host runs under the Centralu app, which stops it: quit the app instead"), { code: "internal" });
@@ -116759,6 +118003,143 @@ function onExternalAppListChanged(apps, emit) {
116759
118003
  });
116760
118004
  }
116761
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
+
116762
118143
  // packages/agent-host/src/app-change-events.ts
116763
118144
  var APP_CHANGE_WINDOW_MS = 250;
116764
118145
  function broadcastAppChanges(send, windowMs = APP_CHANGE_WINDOW_MS) {
@@ -117228,14 +118609,16 @@ var CommandRunner = class {
117228
118609
  };
117229
118610
 
117230
118611
  // packages/agent-host/src/updates.ts
117231
- import { execFile as execFile8 } from "node:child_process";
117232
- 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";
117233
118615
  var REGISTRY_URL = `https://registry.npmjs.org/${APP_SLUG}/latest`;
117234
118616
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
117235
118617
  var SCHEDULED_TIMEOUT_MS = 3e3;
117236
118618
  var FORCED_TIMEOUT_MS = 8e3;
117237
118619
  var INSTALL_CHECK_MAX_AGE_MS = 6e4;
117238
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];
117239
118622
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
117240
118623
  var NPM_RETRY_DELAY_MS = 3e4;
117241
118624
  var NPM_RETRY_WINDOW_MS = 10 * 60 * 1e3;
@@ -117243,6 +118626,16 @@ function existingInstalledCopy() {
117243
118626
  const path2 = installedCopyPath();
117244
118627
  return existsSync21(path2) ? path2 : null;
117245
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
+ }
117246
118639
  async function fetchLatestFromRegistry(timeoutMs) {
117247
118640
  try {
117248
118641
  const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(timeoutMs) });
@@ -117282,7 +118675,13 @@ var UpdateService = class {
117282
118675
  fetchLatest: deps.fetchLatest ?? fetchLatestFromRegistry,
117283
118676
  run: deps.run ?? runCommand,
117284
118677
  installedCopy: deps.installedCopy ?? existingInstalledCopy,
118678
+ installedCopyVersion: deps.installedCopyVersion ?? ((copy) => readInstalledCopyVersion(copy)),
117285
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
+ }),
117286
118685
  // If no place to persist it is given, treat auto-update as on — the same default that main.ts
117287
118686
  // passes in.
117288
118687
  readAuto: deps.readAuto ?? (() => true),
@@ -117322,6 +118721,10 @@ var UpdateService = class {
117322
118721
  deps;
117323
118722
  /** One check at a time — the timer and the button can land together */
117324
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;
117325
118728
  current() {
117326
118729
  return { ...this.status };
117327
118730
  }
@@ -117342,6 +118745,7 @@ var UpdateService = class {
117342
118745
  stop() {
117343
118746
  this.stopped = true;
117344
118747
  this.stopTimer();
118748
+ this.cancelRetry();
117345
118749
  }
117346
118750
  stopTimer() {
117347
118751
  if (this.timer) clearInterval(this.timer);
@@ -117385,13 +118789,46 @@ var UpdateService = class {
117385
118789
  this.deps.writeAutoApply(enabled);
117386
118790
  this.emit();
117387
118791
  if (enabled) this.installIfAutomatic();
118792
+ else this.cancelRetry();
117388
118793
  return this.current();
117389
118794
  }
117390
- /** 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
+ */
117391
118801
  installIfAutomatic() {
117392
118802
  if (!this.status.autoApply || !this.status.newer || !this.status.latest) return;
117393
- if (this.status.phase !== "idle") return;
117394
- 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;
117395
118832
  }
117396
118833
  /**
117397
118834
  * Look at the registry, or answer with what we already know.
@@ -117441,63 +118878,94 @@ var UpdateService = class {
117441
118878
  * Install the newer version, then say so. **Does not restart the app** — that is the
117442
118879
  * window's step (#352).
117443
118880
  *
117444
- * Returns the moment the work starts. `npm i -g` regularly takes longer than the RPC
117445
- * deadline, and a caller that has already given up cannot be told how it went; the
117446
- * 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.
117447
118891
  */
117448
118892
  apply() {
117449
- if (this.stopped || this.installing || this.status.phase === "restart_required") return this.current();
117450
- const latest = this.status.latest;
117451
- if (!this.status.newer || !latest) {
117452
- this.status = { ...this.status, phase: "failed", error: "There is no newer version to install" };
117453
- this.emit();
117454
- return this.current();
117455
- }
118893
+ if (this.stopped || this.installing || this.status.phase === "restart_required") return Promise.resolve(this.current());
117456
118894
  this.installing = true;
117457
- this.status = { ...this.status, phase: "updating", error: null };
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 };
117458
118898
  this.emit();
117459
- void this.runApply(latest);
117460
- return this.current();
118899
+ return new Promise((settled) => void this.runApply(known, settled));
117461
118900
  }
117462
- async runApply(version2) {
118901
+ async runApply(known, settled) {
118902
+ let version2 = known;
117463
118903
  let packageInstalled = false;
118904
+ let answered = false;
118905
+ const answer = () => {
118906
+ if (answered) return;
118907
+ answered = true;
118908
+ settled(this.current());
118909
+ };
117464
118910
  const copy = this.deps.installedCopy();
117465
118911
  try {
117466
118912
  await this.inFlight;
117467
118913
  if (this.stopped) return;
117468
118914
  const age2 = this.status.checkedAt === null ? Infinity : this.deps.now() - this.status.checkedAt;
117469
- if (age2 < 0 || age2 >= INSTALL_CHECK_MAX_AGE_MS) {
118915
+ if (version2 === null || age2 < 0 || age2 >= INSTALL_CHECK_MAX_AGE_MS) {
117470
118916
  const fresh = await this.deps.fetchLatest(FORCED_TIMEOUT_MS);
117471
118917
  if (this.stopped) return;
117472
118918
  if (fresh.ok) {
117473
118919
  const now = this.deps.now();
117474
118920
  const storedAge = this.status.checkedAt === null ? Infinity : now - this.status.checkedAt;
117475
- const keepStored = storedAge >= 0 && storedAge < STORED_RELEASE_MAX_AGE_MS && isNewerVersion(version2, fresh.version);
117476
- if (!keepStored) version2 = fresh.version;
117477
- this.status = { ...this.status, latest: version2, newer: isNewerVersion(version2, this.status.current), checkedAt: keepStored ? this.status.checkedAt : now };
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 };
117478
118925
  if (!this.status.newer) {
117479
118926
  this.status = { ...this.status, phase: "idle", error: null };
117480
118927
  return;
117481
118928
  }
118929
+ } else if (version2 === null) {
118930
+ this.status = { ...this.status, phase: "idle", error: fresh.reason };
118931
+ return;
117482
118932
  } else {
117483
118933
  this.status = { ...this.status, error: `Could not refresh latest (${fresh.reason}); using previously checked ${version2}.` };
117484
118934
  }
117485
118935
  }
118936
+ if (version2 === null) return;
118937
+ this.status = { ...this.status, phase: "updating" };
117486
118938
  this.emit();
118939
+ answer();
117487
118940
  await this.installPackage(version2);
117488
118941
  if (this.stopped) return;
117489
118942
  packageInstalled = true;
117490
- if (copy) await this.deps.run(APP_SLUG, ["install"]);
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
+ }
117491
118952
  if (this.stopped) return;
117492
118953
  this.status = { ...this.status, phase: "restart_required" };
118954
+ this.retry = null;
117493
118955
  } catch (e) {
117494
118956
  if (this.stopped) return;
117495
118957
  const reason = e.message;
117496
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;
117497
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() };
117498
118962
  } finally {
117499
118963
  this.installing = false;
117500
- if (!this.stopped) this.emit();
118964
+ if (!this.stopped) {
118965
+ this.emit();
118966
+ if (this.status.phase === "failed") this.installIfAutomatic();
118967
+ }
118968
+ answer();
117501
118969
  }
117502
118970
  }
117503
118971
  /**
@@ -117537,6 +119005,9 @@ var UpdateService = class {
117537
119005
  function npmHasNotServedVersion(err) {
117538
119006
  return /(?:\bETARGET\b|\bnotarget\b)/i.test(err instanceof Error ? err.message : String(err));
117539
119007
  }
119008
+ function retryDelay(failures) {
119009
+ return AUTO_RETRY_DELAYS_MS[Math.min(Math.max(failures, 1), AUTO_RETRY_DELAYS_MS.length) - 1];
119010
+ }
117540
119011
 
117541
119012
  // packages/agent-host/src/agent-versions.ts
117542
119013
  var CHECK_INTERVAL_MS2 = 10 * 60 * 1e3;
@@ -117879,16 +119350,16 @@ var SessionSleeper = class {
117879
119350
  };
117880
119351
 
117881
119352
  // packages/agent-host/src/dev-services/instance-lock.ts
117882
- import { execFileSync as execFileSync4 } from "node:child_process";
117883
- import { readFileSync as readFileSync13, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "node:fs";
117884
- 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";
117885
119356
  import Database2 from "better-sqlite3";
117886
119357
  var OWNERSHIP_FILE = "host-ownership.sqlite";
117887
119358
  var held = /* @__PURE__ */ new Set();
117888
119359
  function processStartTime(pid) {
117889
119360
  if (!hasPs()) return null;
117890
119361
  try {
117891
- const out = execFileSync4("ps", ["-o", "lstart=", "-p", String(pid)], {
119362
+ const out = execFileSync5("ps", ["-o", "lstart=", "-p", String(pid)], {
117892
119363
  encoding: "utf8",
117893
119364
  timeout: 2e3,
117894
119365
  stdio: ["ignore", "pipe", "ignore"],
@@ -117915,15 +119386,15 @@ function parseHolder(raw) {
117915
119386
  function acquireInstanceLock(dbPath2, startOf = processStartTime) {
117916
119387
  if (dbPath2 === ":memory:") return { ok: true, release: () => {
117917
119388
  } };
117918
- const lockPath = join31(dirname10(dbPath2), "host.lock");
119389
+ const lockPath = join32(dirname11(dbPath2), "host.lock");
117919
119390
  const readHolder = () => {
117920
119391
  try {
117921
- return parseHolder(readFileSync13(lockPath, "utf8"));
119392
+ return parseHolder(readFileSync14(lockPath, "utf8"));
117922
119393
  } catch {
117923
119394
  return null;
117924
119395
  }
117925
119396
  };
117926
- const owner = takeOwnership(join31(dirname10(dbPath2), OWNERSHIP_FILE));
119397
+ const owner = takeOwnership(join32(dirname11(dbPath2), OWNERSHIP_FILE));
117927
119398
  if (!owner) {
117928
119399
  const holder = readHolder();
117929
119400
  return { ok: false, heldByPid: holder && Number.isInteger(holder.pid) && holder.pid > 0 ? holder.pid : null, lockPath };
@@ -117936,7 +119407,7 @@ function acquireInstanceLock(dbPath2, startOf = processStartTime) {
117936
119407
  if (released) return;
117937
119408
  released = true;
117938
119409
  try {
117939
- if (parseHolder(readFileSync13(lockPath, "utf8")).pid === process.pid) unlinkSync3(lockPath);
119410
+ if (parseHolder(readFileSync14(lockPath, "utf8")).pid === process.pid) unlinkSync3(lockPath);
117940
119411
  } catch {
117941
119412
  }
117942
119413
  giveUp(owner);
@@ -118351,7 +119822,7 @@ async function runDrain(bound, deps) {
118351
119822
  }
118352
119823
 
118353
119824
  // packages/agent-host/src/links/install.ts
118354
- import { existsSync as existsSync22, readFileSync as readFileSync14 } from "node:fs";
119825
+ import { existsSync as existsSync22, readFileSync as readFileSync15 } from "node:fs";
118355
119826
  import { hostname as hostname3 } from "node:os";
118356
119827
  import { fileURLToPath as fileURLToPath6 } from "node:url";
118357
119828
  import { gzipSync } from "node:zlib";
@@ -119029,7 +120500,7 @@ async function installRemote(o) {
119029
120500
  function bundled(name, source) {
119030
120501
  for (const url2 of [new URL(`./${name}`, import.meta.url), new URL(source, import.meta.url)]) {
119031
120502
  const file2 = fileURLToPath6(url2);
119032
- if (existsSync22(file2)) return readFileSync14(file2, "utf8");
120503
+ if (existsSync22(file2)) return readFileSync15(file2, "utf8");
119033
120504
  }
119034
120505
  throw new Error(`${name} is missing from this host`);
119035
120506
  }
@@ -119156,9 +120627,9 @@ function probeTarget(target, opts = {}) {
119156
120627
 
119157
120628
  // packages/agent-host/src/links/ssh-config.ts
119158
120629
  import { spawn as spawn8 } from "node:child_process";
119159
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync13 } from "node:fs";
120630
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16, statSync as statSync13 } from "node:fs";
119160
120631
  import { homedir as homedir13 } from "node:os";
119161
- 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";
119162
120633
  var MAX_DEPTH2 = 16;
119163
120634
  function splitConfigLine(raw) {
119164
120635
  const m = /^\s*([^\s=#]+)\s*(?:=\s*)?([\s\S]*)$/.exec(raw);
@@ -119224,7 +120695,7 @@ function expandGlob(path2) {
119224
120695
  const next = [];
119225
120696
  for (const base2 of found) {
119226
120697
  if (!MAGIC.test(seg)) {
119227
- next.push(join32(base2, seg));
120698
+ next.push(join33(base2, seg));
119228
120699
  continue;
119229
120700
  }
119230
120701
  let names;
@@ -119236,7 +120707,7 @@ function expandGlob(path2) {
119236
120707
  const re2 = segmentRe(seg);
119237
120708
  for (const name of names.sort()) {
119238
120709
  if (name.startsWith(".") && !seg.startsWith(".")) continue;
119239
- if (re2.test(name)) next.push(join32(base2, name));
120710
+ if (re2.test(name)) next.push(join33(base2, name));
119240
120711
  }
119241
120712
  }
119242
120713
  found = next;
@@ -119266,7 +120737,7 @@ function readSshConfig(opts) {
119266
120737
  }
119267
120738
  let text2;
119268
120739
  try {
119269
- text2 = readFileSync15(file2, "utf8");
120740
+ text2 = readFileSync16(file2, "utf8");
119270
120741
  } catch (err) {
119271
120742
  if (depth > 0 && err.code !== "ENOENT") notes.push(`${tildePath(file2, opts.home)} could not be read`);
119272
120743
  return;
@@ -119291,11 +120762,11 @@ function readSshConfig(opts) {
119291
120762
  }
119292
120763
  for (const arg of split2.args) {
119293
120764
  let path2 = arg;
119294
- if (path2.startsWith("~/")) path2 = join32(opts.home, path2.slice(2));
120765
+ if (path2.startsWith("~/")) path2 = join33(opts.home, path2.slice(2));
119295
120766
  else if (path2.startsWith("~")) {
119296
120767
  notes.push(`An Include of another user's folder (${where(file2, n + 1)}) was not read`);
119297
120768
  continue;
119298
- } else if (!isAbsolute10(path2)) path2 = join32(opts.home, ".ssh", path2);
120769
+ } else if (!isAbsolute10(path2)) path2 = join33(opts.home, ".ssh", path2);
119299
120770
  for (const f of expandGlob(path2)) read(f, depth + 1);
119300
120771
  }
119301
120772
  }
@@ -119345,7 +120816,7 @@ var MAX_HOSTS = 100;
119345
120816
  var RESOLVE_AT_ONCE = 8;
119346
120817
  async function listSshHosts(opts = {}) {
119347
120818
  const home = opts.home ?? homedir13();
119348
- const file2 = opts.configFile ?? join32(home, ".ssh", "config");
120819
+ const file2 = opts.configFile ?? join33(home, ".ssh", "config");
119349
120820
  const read = readSshConfig({ file: file2, home });
119350
120821
  const notes = [...read.notes];
119351
120822
  const listed = read.hosts.slice(0, MAX_HOSTS);
@@ -119708,7 +121179,10 @@ var Qualifier = class {
119708
121179
  const sid = typeof e.sessionId === "string" ? this.id(e.sessionId) : void 0;
119709
121180
  const base2 = { ...e, ...sid !== void 0 ? { sessionId: sid } : {} };
119710
121181
  switch (e.type) {
121182
+ // Another machine's agent asking its own windows to apply (#665) must never relaunch the hub's window
119711
121183
  case "update_status":
121184
+ case "update_apply_request":
121185
+ case "update_apply_go":
119712
121186
  case "themes_changed":
119713
121187
  case "app_state_changed":
119714
121188
  case "agent_versions":
@@ -120733,7 +122207,7 @@ function linkedSessions(machines, route = notRouted, hubName2) {
120733
122207
  async updateSettings(sessionId, s) {
120734
122208
  const m = owner(sessionId);
120735
122209
  if (!m) return { ok: false, error: `Not a session of a linked machine: ${sessionId}` };
120736
- 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" }) }));
120737
122211
  },
120738
122212
  async findProjects(ref) {
120739
122213
  const wanted = ref.trim();
@@ -121128,6 +122602,9 @@ var ROUTES = {
121128
122602
  "updates.setAuto": HUB,
121129
122603
  "updates.setAutoApply": HUB,
121130
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,
121131
122608
  "machines.list": HUB,
121132
122609
  "machines.add": HUB,
121133
122610
  "machines.sshHosts": HUB,
@@ -121451,7 +122928,7 @@ function storeMirror(store2) {
121451
122928
  var start = new StartSequence();
121452
122929
  start.at("launch variables");
121453
122930
  var launch = takeLaunchEnv();
121454
- var BUILD = true ? "8dd78b6" : "dev";
122931
+ var BUILD = true ? "29ffab5" : "dev";
121455
122932
  var { values } = parseArgs({
121456
122933
  options: {
121457
122934
  port: { type: "string", default: "5175" },
@@ -121472,17 +122949,17 @@ start.at("data folder");
121472
122949
  var movedNote = null;
121473
122950
  function defaultDbPath() {
121474
122951
  const isDev = process.env.CC_DEV === "1";
121475
- const dir = join33(homedir14(), isDev ? DATA_DIR_DEV : DATA_DIR);
121476
- 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);
121477
122954
  movedNote = migrateLegacyDataDir(legacy, dir) ? `[agent-host] data folder moved: ${legacy} -> ${dir}` : null;
121478
122955
  mkdirSync11(dir, { recursive: true });
121479
122956
  process.env.CC_DATA_DIR = dir;
121480
- return join33(dir, "store.db");
122957
+ return join34(dir, "store.db");
121481
122958
  }
121482
122959
  var dbPath = values.memory ? ":memory:" : values.db ?? defaultDbPath();
121483
122960
  start.at("log");
121484
122961
  var stopLog = dbPath === ":memory:" ? () => {
121485
- } : teeStderrToFile(hostLogPath(dirname11(dbPath)));
122962
+ } : teeStderrToFile(hostLogPath(dirname12(dbPath)));
121486
122963
  if (dbPath !== ":memory:") console.error(startupBanner({ build: BUILD, db: dbPath, pid: process.pid }));
121487
122964
  if (movedNote) console.error(movedNote);
121488
122965
  start.at("path");
@@ -121538,7 +123015,7 @@ try {
121538
123015
  }
121539
123016
  var adapters = createAdapters();
121540
123017
  start.at("keeper children");
121541
- var held2 = launch.underKeeper ? await connectHeldChildren(dirname11(dbPath)) : null;
123018
+ var held2 = launch.underKeeper ? await connectHeldChildren(dirname12(dbPath)) : null;
121542
123019
  start.at("services");
121543
123020
  var screens = new Gate();
121544
123021
  var events = new Gate();
@@ -121555,7 +123032,7 @@ var mgr = new SessionManager(
121555
123032
  },
121556
123033
  (sessionId) => bridgeAddress(launch.frontDoor, port, token, sessionId),
121557
123034
  // Worktrees are created next to the data folder — dev and the packaged app never touch each other's worktrees
121558
- join33(dirname11(dbPath), "worktrees"),
123035
+ join34(dirname12(dbPath), "worktrees"),
121559
123036
  held2 ? { processes: held2.processes, keptSessions: held2.kept.sessionIds } : {}
121560
123037
  );
121561
123038
  mgr.useProjectsHome(() => projectsHome());
@@ -121589,7 +123066,7 @@ if (held2) {
121589
123066
  }
121590
123067
  var AUTO_UPDATE_CHECK_KEY = "updates.auto";
121591
123068
  var AUTO_APPLY_UPDATES_KEY = "updates.autoApply";
121592
- 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" }));
121593
123070
  await themes.start().catch((e) => console.error(`[themes] the themes folder is unavailable: ${e.message}`));
121594
123071
  var updates = new UpdateService((status) => events.push({ type: "update_status", status }), {
121595
123072
  // On by default when nothing has been saved yet. Since the check is read-only and every failure
@@ -121603,6 +123080,8 @@ var updates = new UpdateService((status) => events.push({ type: "update_status",
121603
123080
  writeAutoApply: (enabled) => store.setAppSetting(AUTO_APPLY_UPDATES_KEY, String(enabled))
121604
123081
  });
121605
123082
  mgr.useUpdates(updates);
123083
+ var updateApply = new UpdateApplyBroker({ push: (e) => events.push(e), windows: () => server.clientCount });
123084
+ mgr.useUpdateApply(updateApply);
121606
123085
  var AUTO_APPLY_AGENT_VERSIONS_KEY = "agents.autoApplyVersions";
121607
123086
  var AGENT_VERSIONS_SEEN_KEY = "agents.versionsSeen";
121608
123087
  var agentVersions = new AgentVersionService({
@@ -121710,7 +123189,8 @@ var router = new Router({
121710
123189
  heldPids: held2?.heldPids,
121711
123190
  machines: links,
121712
123191
  stopHost: launch.startedByServe && !launch.underKeeper ? () => stopFromRequest() : void 0,
121713
- activity: () => activityCounts(activity())
123192
+ activity: () => activityCounts(activity()),
123193
+ updateApply
121714
123194
  }),
121715
123195
  machines: () => links.all()
121716
123196
  });
@@ -121721,7 +123201,10 @@ var server = new HostServer({
121721
123201
  allowedOrigins,
121722
123202
  onRpc: router.handle,
121723
123203
  // A window's connection ended: the pinned views it leased wait for a claim, then close (#392, view-host.ts)
121724
- onClientGone: (client) => views.holderGone(client),
123204
+ onClientGone: (client) => {
123205
+ views.holderGone(client);
123206
+ updateApply.clientGone(client);
123207
+ },
121725
123208
  // Every HTTP route sits behind this secret (transport/http.ts)
121726
123209
  http: { secret: httpSecret, routes: views.routes },
121727
123210
  // Which build this is and where it came from, in every hello_ok (#280, keeper-link.ts)
@@ -121808,7 +123291,7 @@ var endings = wireEndings({
121808
123291
  heldChildren: held2 !== null,
121809
123292
  leave: (mode, handOver) => void shutdown.leave(mode, handOver),
121810
123293
  force: (why) => shutdown.force(why),
121811
- 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)),
121812
123295
  stdin: process.stdin,
121813
123296
  watchParent: values["watch-parent"] === true,
121814
123297
  log: (line) => console.error(line)