@abelxiaoxing/cadence 2.0.2 → 2.0.3

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.
@@ -26,10 +26,14 @@ export function refused(): never {
26
26
  export function hash(bytes: string | Buffer): string {
27
27
  return createHash("sha256").update(bytes).digest("hex");
28
28
  }
29
- function privateMode(st: Stats): void {
29
+ /** Owner-only mode for state Cadence creates. External sources it only reads
30
+ * (Pi's agent directory and files) keep whatever mode the user's umask gave
31
+ * them, such as 0755/0775 from Pi itself; ownership is still required. */
32
+ function privateMode(st: Stats, ownerOnly = true): void {
30
33
  if (
31
34
  process.platform !== "win32" &&
32
- ((st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()))
35
+ ((ownerOnly && (st.mode & 0o077) !== 0) ||
36
+ (process.getuid && st.uid !== process.getuid()))
33
37
  )
34
38
  refused();
35
39
  }
@@ -80,7 +84,7 @@ export class DirectoryWalks {
80
84
  remember(path: string, st: Stats): void {
81
85
  if (this.open()) this.walked.set(path, { dev: st.dev, ino: st.ino });
82
86
  }
83
- reuse(path: string): boolean {
87
+ reuse(path: string, ownerOnly = true): boolean {
84
88
  const walked = this.open() ? this.walked.get(path) : undefined;
85
89
  if (!walked) return false;
86
90
  let st: Stats;
@@ -100,7 +104,7 @@ export class DirectoryWalks {
100
104
  this.walked.delete(path);
101
105
  return false;
102
106
  }
103
- privateMode(st);
107
+ privateMode(st, ownerOnly);
104
108
  return true;
105
109
  }
106
110
  }
@@ -109,14 +113,18 @@ export class DirectoryWalks {
109
113
  * or hostile-same-user guarantee; failed temporaries are deliberately preserved.
110
114
  */
111
115
  export class PrivateFiles {
112
- constructor(readonly namespace: string) {}
116
+ /** `ownerOnly: false` for an external namespace such as Pi's agent directory. */
117
+ constructor(
118
+ readonly namespace: string,
119
+ readonly ownerOnly = true,
120
+ ) {}
113
121
  directory(path: string, create = false, walks?: DirectoryWalks): boolean {
114
122
  const tail = relative(this.namespace, path);
115
123
  if (isAbsolute(tail) || tail === ".." || tail.startsWith(`..${sep}`))
116
124
  refused();
117
125
  if (!create) {
118
126
  const full = resolve(path);
119
- if (walks?.reuse(full)) return true;
127
+ if (walks?.reuse(full, this.ownerOnly)) return true;
120
128
  const namespace = resolve(this.namespace);
121
129
  let inside = false,
122
130
  last: Stats | undefined;
@@ -126,7 +134,7 @@ export class PrivateFiles {
126
134
  // including an unsafe parent before a missing child. Only the caller's
127
135
  // DirectoryWalks keeps the result, for that same synchronous operation.
128
136
  if (at === namespace) inside = true;
129
- if (inside) privateMode(st);
137
+ if (inside) privateMode(st, this.ownerOnly);
130
138
  last = st;
131
139
  });
132
140
  if (found && last) walks?.remember(full, last);
@@ -135,7 +143,7 @@ export class PrivateFiles {
135
143
  // Walk the namespace and its ambient ancestors once, then check each
136
144
  // private segment exactly once, creating a missing one privately.
137
145
  if (!safeDirectory(this.namespace, true)) return false;
138
- privateMode(lstatSync(this.namespace));
146
+ privateMode(lstatSync(this.namespace), this.ownerOnly);
139
147
  let at = this.namespace;
140
148
  for (const part of tail.split(sep).filter(Boolean)) {
141
149
  at = join(at, part);
@@ -160,7 +168,7 @@ export class PrivateFiles {
160
168
  }
161
169
  if (!st.isDirectory() || st.isSymbolicLink())
162
170
  throw new Error("Unsafe directory");
163
- privateMode(st);
171
+ privateMode(st, this.ownerOnly);
164
172
  }
165
173
  return true;
166
174
  }
@@ -179,7 +187,7 @@ export class PrivateFiles {
179
187
  }
180
188
  if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
181
189
  refused();
182
- privateMode(before);
190
+ privateMode(before, this.ownerOnly);
183
191
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
184
192
  try {
185
193
  const opened = fstatSync(fd);
@@ -2510,6 +2510,23 @@ function hostSystem(root) {
2510
2510
  "--value"
2511
2511
  ]);
2512
2512
  return r.status === 0 ? r.stdout.trim() === "yes" : undefined;
2513
+ },
2514
+ enableLinger: (interactive) => {
2515
+ const r = interactive ? spawnSync2("loginctl", ["enable-linger"], { stdio: "inherit" }) : spawnSync2("busctl", [
2516
+ "--system",
2517
+ "--allow-interactive-authorization=no",
2518
+ "--timeout=15",
2519
+ "call",
2520
+ "org.freedesktop.login1",
2521
+ "/org/freedesktop/login1",
2522
+ "org.freedesktop.login1.Manager",
2523
+ "SetUserLinger",
2524
+ "ubb",
2525
+ String(process.getuid?.()),
2526
+ "true",
2527
+ "false"
2528
+ ], { stdio: "ignore", timeout: 20000 });
2529
+ return !r.error && r.status === 0;
2513
2530
  }
2514
2531
  };
2515
2532
  }
@@ -2672,7 +2689,17 @@ function regularFile(path) {
2672
2689
  return false;
2673
2690
  }
2674
2691
  }
2675
- function configureAutostart(s, mode, requested) {
2692
+ function bootStart(s, attempt, interactive) {
2693
+ const current = s.linger();
2694
+ if (current === undefined)
2695
+ return {};
2696
+ if (current)
2697
+ return { linger: "on" };
2698
+ if (!attempt)
2699
+ return { linger: "off" };
2700
+ return { linger: s.enableLinger(interactive) ? "enabled" : "refused" };
2701
+ }
2702
+ function configureAutostart(s, mode, requested, linger = false) {
2676
2703
  if (s.platform !== "linux")
2677
2704
  return { status: "skipped" };
2678
2705
  if (mode === "refresh" && (s.env.CADENCE_AUTOSTART === "0" || !piUserInstall(s)))
@@ -2714,11 +2741,12 @@ function configureAutostart(s, mode, requested) {
2714
2741
  return { status: "failed", ...result };
2715
2742
  if (!wanted && !active)
2716
2743
  return { status: "disabled", ...result };
2744
+ const boot = () => wanted ? bootStart(s, mode === "install" ? linger : previous === undefined && s.env.CADENCE_LINGER !== "0", mode === "install") : {};
2717
2745
  if (!regularFile(join2(agentDir, "auth.json")))
2718
- return { status: "waiting-for-pi-login", ...result };
2746
+ return { status: "waiting-for-pi-login", ...result, ...boot() };
2719
2747
  s.systemctl(["reset-failed", s.unit]);
2720
2748
  const started = s.systemctl(["start", s.unit]).status === 0;
2721
- return { status: started ? "started" : "failed", ...result };
2749
+ return { status: started ? "started" : "failed", ...result, ...boot() };
2722
2750
  }
2723
2751
  function removeAutostart(s) {
2724
2752
  if (s.platform !== "linux")
@@ -2751,7 +2779,8 @@ function autostartStatus(s) {
2751
2779
  status: s.systemctl(["is-active", s.unit]).stdout.trim() || "unknown",
2752
2780
  enabled: s.systemctl(["is-enabled", s.unit]).stdout.trim() || "unknown",
2753
2781
  unit: path,
2754
- workspace: generatedWorkspace(previous)
2782
+ workspace: generatedWorkspace(previous),
2783
+ ...bootStart(s, false, false)
2755
2784
  };
2756
2785
  }
2757
2786
 
@@ -2786,8 +2815,8 @@ function refused() {
2786
2815
  function hash(bytes) {
2787
2816
  return createHash2("sha256").update(bytes).digest("hex");
2788
2817
  }
2789
- function privateMode(st) {
2790
- if (process.platform !== "win32" && ((st.mode & 63) !== 0 || process.getuid && st.uid !== process.getuid()))
2818
+ function privateMode(st, ownerOnly = true) {
2819
+ if (process.platform !== "win32" && (ownerOnly && (st.mode & 63) !== 0 || process.getuid && st.uid !== process.getuid()))
2791
2820
  refused();
2792
2821
  }
2793
2822
  function same(a, b) {
@@ -2822,7 +2851,7 @@ class DirectoryWalks {
2822
2851
  if (this.open())
2823
2852
  this.walked.set(path, { dev: st.dev, ino: st.ino });
2824
2853
  }
2825
- reuse(path) {
2854
+ reuse(path, ownerOnly = true) {
2826
2855
  const walked = this.open() ? this.walked.get(path) : undefined;
2827
2856
  if (!walked)
2828
2857
  return false;
@@ -2839,15 +2868,17 @@ class DirectoryWalks {
2839
2868
  this.walked.delete(path);
2840
2869
  return false;
2841
2870
  }
2842
- privateMode(st);
2871
+ privateMode(st, ownerOnly);
2843
2872
  return true;
2844
2873
  }
2845
2874
  }
2846
2875
 
2847
2876
  class PrivateFiles {
2848
2877
  namespace;
2849
- constructor(namespace) {
2878
+ ownerOnly;
2879
+ constructor(namespace, ownerOnly = true) {
2850
2880
  this.namespace = namespace;
2881
+ this.ownerOnly = ownerOnly;
2851
2882
  }
2852
2883
  directory(path, create = false, walks) {
2853
2884
  const tail = relative2(this.namespace, path);
@@ -2855,7 +2886,7 @@ class PrivateFiles {
2855
2886
  refused();
2856
2887
  if (!create) {
2857
2888
  const full = resolve3(path);
2858
- if (walks?.reuse(full))
2889
+ if (walks?.reuse(full, this.ownerOnly))
2859
2890
  return true;
2860
2891
  const namespace = resolve3(this.namespace);
2861
2892
  let inside = false, last;
@@ -2863,7 +2894,7 @@ class PrivateFiles {
2863
2894
  if (at2 === namespace)
2864
2895
  inside = true;
2865
2896
  if (inside)
2866
- privateMode(st);
2897
+ privateMode(st, this.ownerOnly);
2867
2898
  last = st;
2868
2899
  });
2869
2900
  if (found && last)
@@ -2872,7 +2903,7 @@ class PrivateFiles {
2872
2903
  }
2873
2904
  if (!safeDirectory(this.namespace, true))
2874
2905
  return false;
2875
- privateMode(lstatSync3(this.namespace));
2906
+ privateMode(lstatSync3(this.namespace), this.ownerOnly);
2876
2907
  let at = this.namespace;
2877
2908
  for (const part of tail.split(sep3).filter(Boolean)) {
2878
2909
  at = join3(at, part);
@@ -2892,7 +2923,7 @@ class PrivateFiles {
2892
2923
  }
2893
2924
  if (!st.isDirectory() || st.isSymbolicLink())
2894
2925
  throw new Error("Unsafe directory");
2895
- privateMode(st);
2926
+ privateMode(st, this.ownerOnly);
2896
2927
  }
2897
2928
  return true;
2898
2929
  }
@@ -2909,7 +2940,7 @@ class PrivateFiles {
2909
2940
  }
2910
2941
  if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
2911
2942
  refused();
2912
- privateMode(before);
2943
+ privateMode(before, this.ownerOnly);
2913
2944
  const fd = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
2914
2945
  try {
2915
2946
  const opened = fstatSync2(fd);
@@ -5843,13 +5874,13 @@ function startupGuidance(result, input) {
5843
5874
  return `Cadence refused its installed package files (web/dist or agents); reinstall the package rather than editing it.
5844
5875
  `;
5845
5876
  case "pi-agent-directory":
5846
- return `Pi's agent directory ${agent} must be an existing real directory owned by you with mode 0700 (chmod 700).
5877
+ return `Pi's agent directory ${agent} must be an existing real directory (not a symlink) owned by you.
5847
5878
  `;
5848
5879
  case "pi-auth":
5849
- return `${join8(agent, "auth.json")} must be your own single-link regular file with mode 0600 holding valid Pi credentials; log in to Pi first.
5880
+ return `${join8(agent, "auth.json")} must be your own single-link regular file holding valid Pi credentials; log in to Pi first.
5850
5881
  `;
5851
5882
  case "pi-models":
5852
- return `${join8(agent, "models.json")} must be your own single-link regular file with mode 0600, using only fields Cadence's pinned Pi SDK supports; a field only newer Pi understands is refused.
5883
+ return `${join8(agent, "models.json")} must be your own single-link regular file using only fields Cadence's pinned Pi SDK supports; a field only newer Pi understands is refused.
5853
5884
  `;
5854
5885
  case "pi-runtime":
5855
5886
  return `Loading Pi's SDK model runtime failed after the owner registered (optional SDK packages missing, or sources the SDK refuses); it stays quarantined.
@@ -5984,15 +6015,30 @@ function autostartGuidance(s, result) {
5984
6015
  ` : "";
5985
6016
  return note + autostartStatusGuidance(s, result, name, cli, workspace, manage);
5986
6017
  }
6018
+ function lingerGuidance(linger) {
6019
+ switch (linger) {
6020
+ case "enabled":
6021
+ return `Linger enabled: Cadence starts at boot and keeps running after logout (undo: loginctl disable-linger).
6022
+ `;
6023
+ case "off":
6024
+ return `Linger is off: Cadence starts at login and stops at logout. Start it at boot: loginctl enable-linger
6025
+ `;
6026
+ case "refused":
6027
+ return `Linger needs authorization: Cadence starts at login and stops at logout. Start it at boot: loginctl enable-linger (may ask for your password)
6028
+ `;
6029
+ default:
6030
+ return "";
6031
+ }
6032
+ }
5987
6033
  function autostartStatusGuidance(s, result, name, cli, workspace, manage) {
5988
6034
  switch (result.status) {
5989
6035
  case "started": {
6036
+ const linger = lingerGuidance(result.linger);
5990
6037
  if (result.service !== "alive")
5991
6038
  return `${s.unit} started for ${result.workspace}, but the owner reports ${result.service}.
5992
6039
  Inspect: node ${cli} diagnose --workspace ${workspace}
5993
6040
  ${manage}
5994
- `;
5995
- const linger = s.linger() === false ? "Linger is off: Cadence starts at login and stops at logout. `loginctl enable-linger` keeps it running and starts it at boot.\n" : "";
6041
+ ${linger}`;
5996
6042
  return `Cadence WebUI: ${result.endpoint} (${s.unit}, workspace ${result.workspace})
5997
6043
  Enter with your private link: node ${cli} token-link --workspace ${workspace} --reveal
5998
6044
  ${manage}
@@ -6001,7 +6047,7 @@ ${linger}`;
6001
6047
  case "waiting-for-pi-login":
6002
6048
  return `${s.unit} is enabled for ${result.workspace}; it starts once Pi has ${join8(agentDirectory(s), "auth.json")}.
6003
6049
  Log in to Pi, then: systemctl --user start ${name}
6004
- `;
6050
+ ${lingerGuidance(result.linger)}`;
6005
6051
  case "disabled":
6006
6052
  return `${s.unit} refreshed for ${result.workspace}; left disabled as configured.
6007
6053
  `;
@@ -6019,7 +6065,7 @@ Log in to Pi, then: systemctl --user start ${name}
6019
6065
  `;
6020
6066
  case "failed":
6021
6067
  return `Could not enable or start ${s.unit}; see systemctl --user status ${name} and journalctl --user -u ${name}.
6022
- `;
6068
+ ${lingerGuidance(result.linger)}`;
6023
6069
  default:
6024
6070
  return "";
6025
6071
  }
@@ -6027,8 +6073,16 @@ Log in to Pi, then: systemctl --user start ${name}
6027
6073
  async function autostartMain(argv, hostUrl) {
6028
6074
  const action = argv.shift();
6029
6075
  let requested;
6030
- if (action === "install" && argv[0] === "--workspace" && argv[1])
6031
- requested = resolve5(argv.splice(0, 2)[1]);
6076
+ let linger = false;
6077
+ while (action === "install" && argv.length) {
6078
+ if (argv[0] === "--workspace" && argv[1] && requested === undefined)
6079
+ requested = resolve5(argv.splice(0, 2)[1]);
6080
+ else if (argv[0] === "--linger" && !linger) {
6081
+ argv.shift();
6082
+ linger = true;
6083
+ } else
6084
+ break;
6085
+ }
6032
6086
  if (argv.length || !["install", "refresh", "remove", "status"].includes(action ?? ""))
6033
6087
  throw new ContractError("invalid");
6034
6088
  const s = hostSystem(dirname8(dirname8(fileURLToPath(hostUrl))));
@@ -6039,7 +6093,7 @@ async function autostartMain(argv, hostUrl) {
6039
6093
  result = autostartStatus(s);
6040
6094
  else {
6041
6095
  try {
6042
- result = configureAutostart(s, action === "install" ? "install" : "refresh", requested);
6096
+ result = configureAutostart(s, action === "install" ? "install" : "refresh", requested, linger);
6043
6097
  } catch (error) {
6044
6098
  if (error instanceof ContractError && error.code === "invalid")
6045
6099
  process.stderr.write(`Cadence autostart needs an existing absolute workspace directory (--workspace or CADENCE_WORKSPACE).
@@ -6062,8 +6116,8 @@ async function managementMain(argv, hostUrl) {
6062
6116
  const command = argv.shift();
6063
6117
  if (command === "--help" && !argv.length) {
6064
6118
  process.stdout.write(`cadence-service start|status|diagnose|stop|token-link --reveal|token-rotate|recover inspect|recover resolve [--workspace PATH] [--state-home PATH]
6065
- cadence-service autostart install [--workspace PATH]|refresh|remove|status
6066
- Start is explicit, or the Linux systemd user unit that Pi's user-scope install registers (CADENCE_AUTOSTART=0, disable or mask opts out). Default detached or --foreground. Start accepts --config, --agent-dir, --host, --port, --token-file.
6119
+ cadence-service autostart install [--workspace PATH] [--linger]|refresh|remove|status
6120
+ Start is explicit, or the Linux systemd user unit that Pi's user-scope install registers (CADENCE_AUTOSTART=0, disable or mask opts out); its first registration also enables linger for boot start when logind allows it without a password (CADENCE_LINGER=0 opts out), and --linger asks for it explicitly. Default detached or --foreground. Start accepts --config, --agent-dir, --host, --port, --token-file.
6067
6121
  Return/quit detaches clients; only explicit Cancel/Stop requests stop. Unconfirmed/quarantined is not stopped.
6068
6122
  Recovery resolve requires this local native terminal, exact unchanged dead-owner resources and separate process/credential/storage acknowledgments. No --yes, network recovery, killing, repair or cleanup.
6069
6123
  `);
@@ -2134,8 +2134,8 @@ function refused() {
2134
2134
  function hash(bytes) {
2135
2135
  return createHash3("sha256").update(bytes).digest("hex");
2136
2136
  }
2137
- function privateMode(st) {
2138
- if (process.platform !== "win32" && ((st.mode & 63) !== 0 || process.getuid && st.uid !== process.getuid()))
2137
+ function privateMode(st, ownerOnly = true) {
2138
+ if (process.platform !== "win32" && (ownerOnly && (st.mode & 63) !== 0 || process.getuid && st.uid !== process.getuid()))
2139
2139
  refused();
2140
2140
  }
2141
2141
  function same(a, b) {
@@ -2170,7 +2170,7 @@ class DirectoryWalks {
2170
2170
  if (this.open())
2171
2171
  this.walked.set(path, { dev: st.dev, ino: st.ino });
2172
2172
  }
2173
- reuse(path) {
2173
+ reuse(path, ownerOnly = true) {
2174
2174
  const walked = this.open() ? this.walked.get(path) : undefined;
2175
2175
  if (!walked)
2176
2176
  return false;
@@ -2187,15 +2187,17 @@ class DirectoryWalks {
2187
2187
  this.walked.delete(path);
2188
2188
  return false;
2189
2189
  }
2190
- privateMode(st);
2190
+ privateMode(st, ownerOnly);
2191
2191
  return true;
2192
2192
  }
2193
2193
  }
2194
2194
 
2195
2195
  class PrivateFiles {
2196
2196
  namespace;
2197
- constructor(namespace) {
2197
+ ownerOnly;
2198
+ constructor(namespace, ownerOnly = true) {
2198
2199
  this.namespace = namespace;
2200
+ this.ownerOnly = ownerOnly;
2199
2201
  }
2200
2202
  directory(path, create = false, walks) {
2201
2203
  const tail = relative2(this.namespace, path);
@@ -2203,7 +2205,7 @@ class PrivateFiles {
2203
2205
  refused();
2204
2206
  if (!create) {
2205
2207
  const full = resolve2(path);
2206
- if (walks?.reuse(full))
2208
+ if (walks?.reuse(full, this.ownerOnly))
2207
2209
  return true;
2208
2210
  const namespace = resolve2(this.namespace);
2209
2211
  let inside = false, last;
@@ -2211,7 +2213,7 @@ class PrivateFiles {
2211
2213
  if (at2 === namespace)
2212
2214
  inside = true;
2213
2215
  if (inside)
2214
- privateMode(st);
2216
+ privateMode(st, this.ownerOnly);
2215
2217
  last = st;
2216
2218
  });
2217
2219
  if (found && last)
@@ -2220,7 +2222,7 @@ class PrivateFiles {
2220
2222
  }
2221
2223
  if (!safeDirectory(this.namespace, true))
2222
2224
  return false;
2223
- privateMode(lstatSync2(this.namespace));
2225
+ privateMode(lstatSync2(this.namespace), this.ownerOnly);
2224
2226
  let at = this.namespace;
2225
2227
  for (const part of tail.split(sep2).filter(Boolean)) {
2226
2228
  at = join2(at, part);
@@ -2240,7 +2242,7 @@ class PrivateFiles {
2240
2242
  }
2241
2243
  if (!st.isDirectory() || st.isSymbolicLink())
2242
2244
  throw new Error("Unsafe directory");
2243
- privateMode(st);
2245
+ privateMode(st, this.ownerOnly);
2244
2246
  }
2245
2247
  return true;
2246
2248
  }
@@ -2257,7 +2259,7 @@ class PrivateFiles {
2257
2259
  }
2258
2260
  if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
2259
2261
  refused();
2260
- privateMode(before);
2262
+ privateMode(before, this.ownerOnly);
2261
2263
  const fd = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
2262
2264
  try {
2263
2265
  const opened = fstatSync2(fd);
@@ -17108,7 +17110,7 @@ function piSource(agentDir, home, cwd, env) {
17108
17110
  const directory = join12(realpathSync4(dirname9(selected2)), selected2.slice(dirname9(selected2).length + 1));
17109
17111
  if (!lstatSync8(directory).isDirectory() || lstatSync8(directory).isSymbolicLink())
17110
17112
  refuse2();
17111
- const files = new PrivateFiles(directory);
17113
+ const files = new PrivateFiles(directory, false);
17112
17114
  if (!files.directory(directory))
17113
17115
  refuse2();
17114
17116
  return {
package/src/task-shell.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-tui";
12
12
  import {
13
13
  autostartHint,
14
+ departure,
14
15
  MANAGEMENT_GUIDANCE,
15
16
  NativeConnection,
16
17
  } from "./native-client.ts";
@@ -98,10 +99,10 @@ export function registerTaskShell(pi: ExtensionAPI): void {
98
99
  dismiss?.();
99
100
  // Detach original attachment, not service jobs/provider/auth. Shutdown does
100
101
  // not wait for their lifetime. Retained transport blocks uncertain reuse.
101
- void connection.detach().catch(() => {});
102
+ return connection.detach().catch(() => {});
102
103
  };
103
104
  pi.on("session_start", (_event, ctx) => {
104
- depart();
105
+ void depart();
105
106
  unsubscribe?.();
106
107
  unsubscribe = undefined;
107
108
  selectedTask = undefined;
@@ -112,15 +113,17 @@ export function registerTaskShell(pi: ExtensionAPI): void {
112
113
  });
113
114
  });
114
115
  pi.on("session_shutdown", () => {
115
- depart();
116
+ const leaving = depart();
116
117
  unsubscribe?.();
117
118
  unsubscribe = undefined;
119
+ return departure(leaving);
118
120
  });
119
- pi.on("session_before_switch", depart);
120
- pi.on("session_before_fork", depart);
121
- pi.on("session_before_tree", depart);
122
- pi.on("session_before_compact", depart);
123
- pi.on("model_select", depart);
121
+ const leave = () => void depart();
122
+ pi.on("session_before_switch", leave);
123
+ pi.on("session_before_fork", leave);
124
+ pi.on("session_before_tree", leave);
125
+ pi.on("session_before_compact", leave);
126
+ pi.on("model_select", leave);
124
127
  // Only local dialog input is owned. Detached service work never owns Pi's
125
128
  // conversation/tool lifetime or silently cancels it.
126
129
  pi.on("input", () => (modal ? { action: "handled" as const } : undefined));