@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.
@@ -0,0 +1,474 @@
1
+ /** Background-agent panel for Pi's TUI. Observation only: it subscribes to
2
+ * the `subagent:*` events on Pi's shared extension bus and renders them; it
3
+ * never starts, cancels or keeps an agent or the Pi process alive. */
4
+ import {
5
+ AssistantMessageComponent,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ getMarkdownTheme,
9
+ type Theme,
10
+ ToolExecutionComponent,
11
+ UserMessageComponent,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ type Component,
15
+ Container,
16
+ Editor,
17
+ isKeyRelease,
18
+ Key,
19
+ type KeyId,
20
+ matchesKey,
21
+ Spacer,
22
+ Text,
23
+ type TUI,
24
+ truncateToWidth,
25
+ } from "@earendil-works/pi-tui";
26
+ import {
27
+ factsText,
28
+ type Paint,
29
+ type PanelAction,
30
+ PanelFocus,
31
+ type PanelKey,
32
+ panelLines,
33
+ SUBAGENT_EVENTS,
34
+ SUBAGENT_VIEW,
35
+ type SubagentState,
36
+ SubagentStore,
37
+ statusSymbol,
38
+ summaryLines,
39
+ } from "./agent-panel-model.ts";
40
+
41
+ export interface AgentPanelOptions {
42
+ /** Agent rows shown at once; more collapse into `+K more`. */
43
+ maxRows?: number;
44
+ /** Keep `● main` visible with no agents (CADENCE_AGENT_PANEL=always). */
45
+ showEmpty?: boolean;
46
+ now?: () => number;
47
+ }
48
+ const PANEL = "cadence-agents",
49
+ SUMMARY = "cadence-agents-summary",
50
+ SPIN_MS = 100;
51
+
52
+ /** Rows kept for Pi's footer/status lines while an agent view is shown. */
53
+ const RESERVED = 5;
54
+ const KEYS: [KeyId, PanelKey][] = [
55
+ [Key.down, "down"],
56
+ [Key.up, "up"],
57
+ [Key.ctrl("n"), "next"],
58
+ [Key.ctrl("p"), "previous"],
59
+ [Key.enter, "enter"],
60
+ [Key.escape, "escape"],
61
+ [Key.ctrl("c"), "interrupt"],
62
+ [Key.pageUp, "pageUp"],
63
+ [Key.pageDown, "pageDown"],
64
+ [Key.ctrl("u"), "halfUp"],
65
+ [Key.ctrl("d"), "halfDown"],
66
+ [Key.home, "home"],
67
+ [Key.end, "end"],
68
+ ];
69
+ /** Key releases (Kitty protocol) stay Pi's. */
70
+ export function panelKey(data: string): PanelKey | undefined {
71
+ if (isKeyRelease(data)) return undefined;
72
+ return KEYS.find(([id]) => matchesKey(data, id))?.[1] ?? "other";
73
+ }
74
+
75
+ const themePaint =
76
+ (theme: Theme): Paint =>
77
+ (role, text) =>
78
+ role === "selected" ? theme.inverse(text) : theme.fg(role, text);
79
+
80
+ export function registerAgentPanel(
81
+ pi: ExtensionAPI,
82
+ options: AgentPanelOptions = {},
83
+ ): void {
84
+ const store = new SubagentStore(options.now);
85
+ for (const channel of Object.values(SUBAGENT_EVENTS))
86
+ pi.events.on(channel, (data) => {
87
+ store.apply(channel, data);
88
+ });
89
+ let detach: (() => void) | undefined;
90
+ pi.on("session_start", (_event, ctx) => {
91
+ detach?.();
92
+ detach =
93
+ ctx.mode === "tui" && ctx.hasUI
94
+ ? attachPanel(ctx, store, options, (id) =>
95
+ pi.events.emit(SUBAGENT_VIEW, { id }),
96
+ )
97
+ : undefined;
98
+ });
99
+ pi.on("session_shutdown", () => {
100
+ detach?.();
101
+ detach = undefined;
102
+ });
103
+ }
104
+
105
+ function attachPanel(
106
+ ctx: ExtensionContext,
107
+ store: SubagentStore,
108
+ options: AgentPanelOptions,
109
+ announce: (id: string | null) => void,
110
+ ): () => void {
111
+ const now = options.now ?? Date.now;
112
+ const maxRows = Math.max(1, options.maxRows ?? 6);
113
+ const showEmpty =
114
+ options.showEmpty ?? process.env.CADENCE_AGENT_PANEL === "always";
115
+ const focus = new PanelFocus(store);
116
+ let tui: TUI | undefined,
117
+ paint: Paint = (_role, text) => text,
118
+ frame = 0,
119
+ spinner: ReturnType<typeof setInterval> | undefined,
120
+ view: { component: Component; done: () => void } | undefined,
121
+ // Widgets mount with the first agent, so ordinary sessions show nothing.
122
+ mounted = false,
123
+ // The agent last announced on `subagent:view`, so producers load only it.
124
+ announced: string | undefined;
125
+ const announceView = () => {
126
+ if (focus.view === announced) return;
127
+ announced = focus.view;
128
+ announce(announced ?? null);
129
+ };
130
+ const transcripts = new Map<string, Transcript>();
131
+ const stopSpinner = () => {
132
+ clearInterval(spinner);
133
+ spinner = undefined;
134
+ };
135
+ // One redraw per spinner frame while anything runs; Pi's differential
136
+ // renderer rewrites only the changed rows.
137
+ const changed = () => {
138
+ if (!mounted && (store.size > 0 || showEmpty)) mount();
139
+ if (store.running() === 0) stopSpinner();
140
+ else if (!spinner) {
141
+ spinner = setInterval(() => {
142
+ frame++;
143
+ changed();
144
+ }, SPIN_MS);
145
+ spinner.unref?.();
146
+ }
147
+ tui?.requestRender();
148
+ };
149
+ const unsubscribe = store.subscribe(changed);
150
+ const focused = () =>
151
+ (
152
+ tui as { getFocusedComponent?(): Component | null } | undefined
153
+ )?.getFocusedComponent?.();
154
+ /** Pi's own input box has focus: no dialog, selector or overlay is up. */
155
+ const editorFocused = () => focused() instanceof Editor && !tui?.hasOverlay();
156
+ const act = (action: PanelAction) => {
157
+ if (action === "open") openView();
158
+ else if (action === "close") view?.done();
159
+ announceView();
160
+ tui?.requestRender();
161
+ return action === "pass" ? undefined : { consume: true };
162
+ };
163
+ const unlisten = ctx.ui.onTerminalInput((data) => {
164
+ if (focus.view !== undefined) {
165
+ // The view handles its own keys. Pi may have put its editor back in the
166
+ // view's slot (another dialog came and went); then the view is gone.
167
+ if (!(focused() instanceof Editor)) return undefined;
168
+ focus.abandon();
169
+ view = undefined;
170
+ announceView();
171
+ }
172
+ if (focus.focus === "panel" && !editorFocused()) {
173
+ // A dialog took focus: its keys are not the panel's.
174
+ focus.focus = "input";
175
+ tui?.requestRender();
176
+ return undefined;
177
+ }
178
+ const key = panelKey(data);
179
+ if (!key) return undefined;
180
+ const idle = ctx.ui.getEditorText() === "" && editorFocused();
181
+ return act(focus.key(key, idle));
182
+ });
183
+
184
+ const openView = () => {
185
+ void ctx.ui
186
+ .custom<void>((_tui, _theme, _keybindings, done) => {
187
+ const component: Component = {
188
+ render: (width) => viewLines(width),
189
+ handleInput(data) {
190
+ const key = panelKey(data);
191
+ if (key) act(focus.key(key, false));
192
+ },
193
+ invalidate() {},
194
+ };
195
+ view = { component, done: () => done() };
196
+ return component;
197
+ })
198
+ .finally(() => {
199
+ view = undefined;
200
+ tui?.requestRender();
201
+ });
202
+ };
203
+ const panelFrame = () => ({
204
+ agents: store.ordered(),
205
+ focus: focus.focus,
206
+ selected: focus.selected,
207
+ view: focus.view,
208
+ now: now(),
209
+ frame,
210
+ maxRows,
211
+ showEmpty,
212
+ });
213
+ const viewLines = (width: number): string[] => {
214
+ const agent = focus.view === undefined ? undefined : store.get(focus.view);
215
+ if (!agent) return [];
216
+ const panelHeight = panelLines(panelFrame(), width, paint).length;
217
+ const height = Math.max(
218
+ 3,
219
+ (tui?.terminal.rows ?? 24) - panelHeight - RESERVED,
220
+ );
221
+ let transcript = transcripts.get(agent.id);
222
+ if (!transcript && tui) {
223
+ transcript = new Transcript(tui, ctx.cwd, ctx.ui.getToolsExpanded());
224
+ transcripts.set(agent.id, transcript);
225
+ }
226
+ const body = transcript?.render(agent, width, paint) ?? [];
227
+ const top = focus.settle(agent.id, body.length, height - 2);
228
+ const rows = body.slice(top, top + height - 2);
229
+ while (rows.length < height - 2) rows.push("");
230
+ return [
231
+ truncateToWidth(banner(agent), width, "…"),
232
+ ...rows,
233
+ truncateToWidth(
234
+ paint(
235
+ "dim",
236
+ "↑↓ select · Enter open · PgUp/PgDn scroll · Esc back to main",
237
+ ),
238
+ width,
239
+ "…",
240
+ ),
241
+ ];
242
+ };
243
+ const banner = (agent: SubagentState): string => {
244
+ if (agent.status === "running")
245
+ return paint(
246
+ "dim",
247
+ `→ viewing: ${agent.label} (Esc to return to main)`,
248
+ );
249
+ const [symbol, role] = statusSymbol(agent, frame);
250
+ const word = { done: "finished", error: "failed", cancelled: "cancelled" }[
251
+ agent.status
252
+ ];
253
+ const facts = factsText(agent, now());
254
+ return `${paint(role, `${symbol} ${word}`)}${paint(
255
+ "dim",
256
+ ` (${facts}) · ${agent.label} (Esc to return to main)`,
257
+ )}`;
258
+ };
259
+
260
+ const mount = () => {
261
+ mounted = true;
262
+ ctx.ui.setWidget(
263
+ SUMMARY,
264
+ (t, theme) => {
265
+ tui = t;
266
+ paint = themePaint(theme);
267
+ return {
268
+ // The agent view takes Z1's place; its summary would sit above it.
269
+ render: (width) =>
270
+ view ? [] : summaryLines(store.ordered(), width, paint, maxRows),
271
+ invalidate() {},
272
+ };
273
+ },
274
+ { placement: "aboveEditor" },
275
+ );
276
+ ctx.ui.setWidget(
277
+ PANEL,
278
+ (t, theme) => {
279
+ tui = t;
280
+ paint = themePaint(theme);
281
+ return {
282
+ render: (width) => panelLines(panelFrame(), width, paint),
283
+ invalidate() {},
284
+ };
285
+ },
286
+ { placement: "belowEditor" },
287
+ );
288
+ };
289
+ changed();
290
+ return () => {
291
+ unsubscribe();
292
+ unlisten();
293
+ stopSpinner();
294
+ if (announced !== undefined) announce(null);
295
+ try {
296
+ view?.done();
297
+ if (mounted) {
298
+ ctx.ui.setWidget(SUMMARY, undefined);
299
+ ctx.ui.setWidget(PANEL, undefined);
300
+ }
301
+ } catch {
302
+ // A replaced runtime already cleared its UI.
303
+ }
304
+ };
305
+ }
306
+
307
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: match OSC 133 terminal marks
308
+ const OSC_133 = /\x1b\]133;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
309
+ type Message = Record<string, unknown> & { content?: unknown };
310
+ const textOf = (content: unknown): string =>
311
+ typeof content === "string"
312
+ ? content
313
+ : Array.isArray(content)
314
+ ? content
315
+ .map((block) =>
316
+ block?.type === "text"
317
+ ? String(block.text ?? "")
318
+ : block?.type === "image"
319
+ ? "[image]"
320
+ : "",
321
+ )
322
+ .filter(Boolean)
323
+ .join("\n")
324
+ : "";
325
+
326
+ /** One agent's messages as Pi's own transcript components, the way Pi renders
327
+ * a resumed session. Only the tail is rebuilt: new messages, a replaced
328
+ * streaming snapshot, or everything once older messages were dropped. */
329
+ class Transcript {
330
+ private readonly root = new Container();
331
+ private parts: Component[][] = [];
332
+ private tail: object | undefined;
333
+ private tailStreaming = false;
334
+ private omitted = 0;
335
+ private revision = -1;
336
+ private readonly tools = new Map<string, ToolExecutionComponent>();
337
+ constructor(
338
+ private readonly tui: TUI,
339
+ private readonly cwd: string,
340
+ private readonly expanded: boolean,
341
+ ) {}
342
+
343
+ render(agent: SubagentState, width: number, paint: Paint): string[] {
344
+ this.sync(agent);
345
+ // Pi marks its own prompts for terminal shell integration; a sliced view
346
+ // of another agent's prompts must not open unmatched marks.
347
+ let lines: string[];
348
+ try {
349
+ lines = this.root.render(width).map((line) => line.replace(OSC_133, ""));
350
+ } catch {
351
+ // Producer data must never break Pi's own render loop.
352
+ lines = [paint("error", " (this transcript could not be rendered)")];
353
+ }
354
+ if (agent.omitted)
355
+ lines.unshift(
356
+ paint("dim", `… ${agent.omitted} earlier messages not retained`),
357
+ );
358
+ if (!agent.messages.length) lines.push(paint("dim", " (no messages yet)"));
359
+ return lines;
360
+ }
361
+ private sync(agent: SubagentState): void {
362
+ if (agent.revision === this.revision) return;
363
+ this.revision = agent.revision;
364
+ const { messages } = agent;
365
+ if (agent.omitted !== this.omitted) {
366
+ this.root.clear();
367
+ this.parts = [];
368
+ this.tools.clear();
369
+ this.omitted = agent.omitted;
370
+ }
371
+ let from = this.parts.length;
372
+ // A streaming snapshot may be replaced or mutated in place.
373
+ if (from > 0 && (this.tailStreaming || messages[from - 1] !== this.tail))
374
+ from--;
375
+ for (const part of this.parts.splice(from))
376
+ for (const component of part) this.root.removeChild(component);
377
+ for (let i = from; i < messages.length; i++) {
378
+ const last = i === messages.length - 1;
379
+ const part = this.build(messages[i] as Message, last && agent.streaming);
380
+ for (const component of part) this.root.addChild(component);
381
+ this.parts.push(part);
382
+ }
383
+ this.tail = messages.at(-1);
384
+ this.tailStreaming = agent.streaming;
385
+ }
386
+ private build(message: Message, streaming: boolean): Component[] {
387
+ switch (message.role) {
388
+ case "user": {
389
+ const text = textOf(message.content);
390
+ return text
391
+ ? [new Spacer(1), new UserMessageComponent(text, getMarkdownTheme())]
392
+ : [];
393
+ }
394
+ case "assistant": {
395
+ // Pi's component assumes well-formed blocks; keep only those.
396
+ const content = (
397
+ Array.isArray(message.content) ? message.content : []
398
+ ).filter(
399
+ (b) =>
400
+ (b?.type === "text" && typeof b.text === "string") ||
401
+ (b?.type === "thinking" && typeof b.thinking === "string") ||
402
+ b?.type === "toolCall",
403
+ );
404
+ const parts: Component[] = [
405
+ new AssistantMessageComponent(
406
+ { ...message, content } as never,
407
+ false,
408
+ getMarkdownTheme(),
409
+ ),
410
+ ];
411
+ const failed =
412
+ message.stopReason === "aborted" || message.stopReason === "error";
413
+ for (const block of content) {
414
+ if (block.type !== "toolCall") continue;
415
+ const tool = this.tool(
416
+ String(block.name ?? "tool"),
417
+ String(block.id ?? ""),
418
+ block.arguments ?? {},
419
+ );
420
+ // Pi's edit renderer previews complete arguments against the file
421
+ // as it is now, where an applied edit no longer matches and would
422
+ // read as a failure; a transcript edit shows its call and result only.
423
+ if (!streaming && block.name !== "edit") tool.setArgsComplete();
424
+ if (failed)
425
+ tool.updateResult({
426
+ content: [
427
+ {
428
+ type: "text",
429
+ text: String(message.errorMessage ?? "Operation aborted"),
430
+ },
431
+ ],
432
+ isError: true,
433
+ });
434
+ parts.push(tool);
435
+ }
436
+ return parts;
437
+ }
438
+ case "toolResult": {
439
+ const result = {
440
+ content: Array.isArray(message.content) ? message.content : [],
441
+ details: message.details,
442
+ isError: message.isError === true,
443
+ };
444
+ const id = String(message.toolCallId ?? "");
445
+ const pending = this.tools.get(id);
446
+ if (pending) {
447
+ pending.updateResult(result, streaming);
448
+ return [];
449
+ }
450
+ const orphan = this.tool(String(message.toolName ?? "tool"), id, {});
451
+ orphan.updateResult(result, streaming);
452
+ return [orphan];
453
+ }
454
+ default: {
455
+ const text = textOf(message.content);
456
+ return text ? [new Spacer(1), new Text(text, 1, 0)] : [];
457
+ }
458
+ }
459
+ }
460
+ private tool(name: string, id: string, args: unknown) {
461
+ const tool = new ToolExecutionComponent(
462
+ name,
463
+ id,
464
+ args,
465
+ {},
466
+ undefined,
467
+ this.tui,
468
+ this.cwd,
469
+ );
470
+ tool.setExpanded(this.expanded);
471
+ this.tools.set(id, tool);
472
+ return tool;
473
+ }
474
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import os from "node:os";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { registerAgentPanel } from "./agent-panel.ts";
4
+ import { registerCadenceAgentFeed } from "./agent-panel-cadence.ts";
5
+ import { autostartNotice } from "./native-client.ts";
3
6
  import { startupCardLines } from "./startup-card.ts";
4
7
  import { registerTaskShell } from "./task-shell.ts";
5
8
 
@@ -8,6 +11,10 @@ import { registerTaskShell } from "./task-shell.ts";
8
11
  export default function register(pi: ExtensionAPI): void {
9
12
  if (process.env.CADENCE_SUBAGENT_CHILD === "1") return;
10
13
  registerTaskShell(pi);
14
+ // Observes `subagent:*` bus events only; renders nothing until one arrives.
15
+ registerAgentPanel(pi);
16
+ // Publishes a running owner's child runs there; readonly, never starts one.
17
+ registerCadenceAgentFeed(pi);
11
18
  pi.on("session_start", (_event, ctx) => {
12
19
  if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) return;
13
20
  // Preserve independent, nonactivating configuration presentation. This
@@ -18,10 +25,11 @@ export default function register(pi: ExtensionAPI): void {
18
25
  home: os.homedir(),
19
26
  projectTrusted: ctx.isProjectTrusted(),
20
27
  });
21
- ctx.ui.notify(
22
- (lines.length > 1 ? lines.slice(1) : lines).join("\n"),
23
- "info",
24
- );
28
+ const shown = lines.length > 1 ? lines.slice(1) : lines;
29
+ // npm hides the install hook's guidance; boot-start gaps surface here.
30
+ const autostart = autostartNotice();
31
+ if (autostart) shown.push(autostart);
32
+ ctx.ui.notify(shown.join("\n"), "info");
25
33
  } catch {
26
34
  // Presentation only; shell lifecycle still runs.
27
35
  }
@@ -1,8 +1,8 @@
1
1
  /** Native attachment only. No SDK/auth resolution, worker or storage mutation. */
2
2
 
3
- import { existsSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { isAbsolute, join } from "node:path";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { Agent, Dispatcher1Wrapper } from "undici";
8
8
  import { nativeBuild } from "./native-build.ts";
@@ -19,20 +19,131 @@ import { effectiveWorkspace } from "./task-store.ts";
19
19
  export const MANAGEMENT_GUIDANCE =
20
20
  "Cadence owner absent, unavailable or incompatible. Explicitly run cadence-service status --workspace <path>, cadence-service diagnose --workspace <path>, or cadence-service start --workspace <path>. Stop older Cadence sessions first. For retained fences use cadence-service recover inspect; resolve requires a local terminal and separate process, credential and storage acknowledgments. Never treat unavailable/quarantined as stopped. No service was started and no embedded executor is available.";
21
21
 
22
+ const UNIT = "cadence.service";
23
+ /** Exists only when systemd booted the machine (sd_booted); without it there
24
+ * is no user manager, and `autostart install` reports unavailable. */
25
+ const SYSTEMD_BOOTED = "/run/systemd/system";
26
+ const xdg = (value: string | undefined, fallback: string) =>
27
+ value && isAbsolute(value) ? value : fallback;
28
+ const cliPath = () =>
29
+ JSON.stringify(fileURLToPath(new URL("./service-cli.mjs", import.meta.url)));
30
+ /** CADENCE_LINGER=0 keeps the explicit command login-only, too. */
31
+ const installCommand = (env: NodeJS.ProcessEnv) =>
32
+ `node ${cliPath()} autostart install${env.CADENCE_LINGER === "0" ? "" : " --linger"}`;
33
+
22
34
  /** Linux: npm may skip or hide the Pi install hook that registers the user
23
- * unit, so name the explicit command when it is absent. Reads one path only. */
35
+ * unit, so name the explicit command when it is absent. Reads two paths only. */
24
36
  export function autostartHint(): string {
25
- if (process.platform !== "linux") return "";
26
- const data = process.env.XDG_DATA_HOME;
27
- const unit = join(
28
- data && isAbsolute(data) ? data : join(homedir(), ".local", "share"),
29
- "systemd",
30
- "user",
31
- "cadence.service",
37
+ if (process.platform !== "linux" || !existsSync(SYSTEMD_BOOTED)) return "";
38
+ const data = xdg(
39
+ process.env.XDG_DATA_HOME,
40
+ join(homedir(), ".local", "share"),
32
41
  );
33
- if (existsSync(unit)) return "";
34
- const cli = fileURLToPath(new URL("./service-cli.mjs", import.meta.url));
35
- return `\nTo run the shared service and WebUI (default http://127.0.0.1:17423) at login: node ${JSON.stringify(cli)} autostart install`;
42
+ if (existsSync(join(data, "systemd", "user", UNIT))) return "";
43
+ return `\nTo run the shared service and WebUI (default http://127.0.0.1:17423) ${process.env.CADENCE_LINGER === "0" ? "at login" : "at boot"}: ${installCommand(process.env)}`;
44
+ }
45
+
46
+ export interface AutostartObservation {
47
+ platform: NodeJS.Platform;
48
+ env: NodeJS.ProcessEnv;
49
+ home: string;
50
+ /** Package root; only Pi's managed user copy registers itself. */
51
+ root: string;
52
+ /** logind keeps one flag file per lingering user name. */
53
+ lingerFile: string;
54
+ /** Exists only on a systemd-booted machine. */
55
+ systemd: string;
56
+ }
57
+ function hostObservation(): AutostartObservation {
58
+ let user = "";
59
+ try {
60
+ user = userInfo().username;
61
+ } catch {
62
+ // No passwd entry: linger is unobservable, so it reads as off.
63
+ }
64
+ return {
65
+ platform: process.platform,
66
+ env: process.env,
67
+ home: homedir(),
68
+ root: fileURLToPath(new URL("..", import.meta.url)),
69
+ lingerFile: user ? join("/var/lib/systemd/linger", user) : "",
70
+ systemd: SYSTEMD_BOOTED,
71
+ };
72
+ }
73
+ /** Same selection as Pi's getAgentDir: PI_CODING_AGENT_DIR or ~/.pi/agent. */
74
+ function piUserCopy(o: AutostartObservation): boolean {
75
+ const selected = o.env.PI_CODING_AGENT_DIR;
76
+ const agent = !selected
77
+ ? join(o.home, ".pi", "agent")
78
+ : resolve(
79
+ selected === "~"
80
+ ? o.home
81
+ : selected.startsWith("~/")
82
+ ? join(o.home, selected.slice(2))
83
+ : selected,
84
+ );
85
+ try {
86
+ const managed = join(
87
+ agent,
88
+ "npm",
89
+ "node_modules",
90
+ "@abelxiaoxing",
91
+ "cadence",
92
+ );
93
+ return realpathSync(managed) === realpathSync(o.root);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ /** Startup-card line while boot start of the Linux user unit needs a human
99
+ * step. Path observations only: nothing is created, spawned or started. A
100
+ * machine without systemd, a disabled or (also runtime-)masked unit and
101
+ * CADENCE_AUTOSTART=0 are quiet; CADENCE_LINGER=0 never suggests linger. */
102
+ export function autostartNotice(
103
+ o: AutostartObservation = hostObservation(),
104
+ ): string | undefined {
105
+ if (
106
+ o.platform !== "linux" ||
107
+ o.env.CADENCE_AUTOSTART === "0" ||
108
+ !existsSync(o.systemd)
109
+ )
110
+ return;
111
+ const data = xdg(o.env.XDG_DATA_HOME, join(o.home, ".local", "share"));
112
+ const config = xdg(o.env.XDG_CONFIG_HOME, join(o.home, ".config"));
113
+ const boot = o.env.CADENCE_LINGER !== "0";
114
+ if (!existsSync(join(data, "systemd", "user", UNIT)))
115
+ return piUserCopy(o)
116
+ ? `Cadence服务:未注册${boot ? "开机" : ""}自启(npm 可能跳过或隐藏了安装脚本)。注册并${boot ? "开机" : "登录后"}启动:${installCommand(o.env)}`
117
+ : undefined;
118
+ const user = join(config, "systemd", "user");
119
+ const runtime = o.env.XDG_RUNTIME_DIR;
120
+ if (
121
+ existsSync(join(user, UNIT)) ||
122
+ (runtime &&
123
+ isAbsolute(runtime) &&
124
+ existsSync(join(runtime, "systemd", "user", UNIT))) ||
125
+ !existsSync(join(user, "default.target.wants", UNIT)) ||
126
+ o.env.CADENCE_LINGER === "0" ||
127
+ existsSync(o.lingerFile)
128
+ )
129
+ return;
130
+ return "Cadence服务:linger 未开启,仅在登录后启动、注销时停止。开机启动:loginctl enable-linger";
131
+ }
132
+
133
+ /** Pi exits right after its session_shutdown handlers settle. An unsent
134
+ * detach leaves the attachment holding one of the owner's viewer slots until
135
+ * its idle expiry, so a departing session waits briefly for the detach; a
136
+ * slow or absent owner never holds Pi's quit longer than `ms`. */
137
+ export async function departure(detaching: Promise<void>, ms = 1500) {
138
+ let timer: ReturnType<typeof setTimeout> | undefined;
139
+ await Promise.race([
140
+ detaching.catch(() => {}),
141
+ new Promise<void>((resolve) => {
142
+ timer = setTimeout(resolve, ms);
143
+ timer.unref?.();
144
+ }),
145
+ ]);
146
+ clearTimeout(timer);
36
147
  }
37
148
 
38
149
  export class NativeConnection {