@pi-unipi/subagents 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +89 -78
  2. package/agents/delegate.md +14 -0
  3. package/agents/oracle.md +78 -0
  4. package/agents/researcher.md +52 -0
  5. package/agents/reviewer.md +79 -0
  6. package/agents/scout.md +50 -0
  7. package/agents/worker.md +59 -0
  8. package/package.json +19 -17
  9. package/prompts/council.md +48 -0
  10. package/prompts/gather-context-and-clarify.md +13 -0
  11. package/prompts/parallel-cleanup.md +59 -0
  12. package/prompts/parallel-research.md +50 -0
  13. package/prompts/parallel-review.md +54 -0
  14. package/prompts/review-loop.md +43 -0
  15. package/skills/council-mode/SKILL.md +230 -0
  16. package/skills/subagents/SKILL.md +49 -0
  17. package/skills/subagents/references/constraints-and-recipes.md +259 -0
  18. package/skills/subagents/references/execution-controls.md +454 -0
  19. package/skills/subagents/references/management-authoring-rpc.md +161 -0
  20. package/skills/subagents/references/multi-lane-orchestration.md +39 -0
  21. package/skills/subagents/references/prompting-and-roles.md +267 -0
  22. package/src/acceptance.ts +331 -0
  23. package/src/agent-manager.ts +405 -0
  24. package/src/agent-memory.ts +300 -0
  25. package/src/agent-overrides.ts +183 -0
  26. package/src/agent-runner.ts +265 -0
  27. package/src/async-runner.ts +417 -0
  28. package/src/authority-policy.ts +47 -0
  29. package/src/budgets.ts +285 -0
  30. package/src/child-safety.ts +140 -0
  31. package/src/config.ts +226 -0
  32. package/src/conversation-viewer.ts +281 -0
  33. package/src/core-compat.ts +94 -0
  34. package/src/custom-agents.ts +323 -0
  35. package/src/file-system-retry.ts +50 -0
  36. package/src/fleet-data.ts +61 -0
  37. package/src/fleet-view.ts +316 -0
  38. package/src/foreground-detach.ts +59 -0
  39. package/src/fork-context.ts +285 -0
  40. package/src/global.d.ts +7 -0
  41. package/src/guide.ts +129 -0
  42. package/src/index.ts +1149 -0
  43. package/src/mission-state.ts +133 -0
  44. package/src/mission-store.ts +434 -0
  45. package/src/model-resolver.ts +79 -0
  46. package/src/output-limits.ts +142 -0
  47. package/src/parity-types.ts +315 -0
  48. package/src/pi-args.ts +161 -0
  49. package/src/pi-spawn.ts +147 -0
  50. package/src/result-files.ts +232 -0
  51. package/src/result-watcher.ts +221 -0
  52. package/src/retained-children.ts +176 -0
  53. package/src/run-fanout-budget.ts +356 -0
  54. package/src/scheduled-runs.ts +351 -0
  55. package/src/schemas.ts +263 -0
  56. package/src/slash-commands.ts +77 -0
  57. package/src/supervisor-channel.ts +249 -0
  58. package/src/tool-handler.ts +1144 -0
  59. package/src/types.ts +238 -0
  60. package/src/widget.ts +447 -0
  61. package/src/workflow-script.ts +737 -0
  62. package/src/workflow-worker.ts +384 -0
  63. package/src/worktree.ts +614 -0
  64. package/dist/agent-manager.d.ts +0 -81
  65. package/dist/agent-manager.d.ts.map +0 -1
  66. package/dist/agent-manager.js +0 -295
  67. package/dist/agent-manager.js.map +0 -1
  68. package/dist/agent-runner.d.ts +0 -51
  69. package/dist/agent-runner.d.ts.map +0 -1
  70. package/dist/agent-runner.js +0 -262
  71. package/dist/agent-runner.js.map +0 -1
  72. package/dist/config.d.ts +0 -24
  73. package/dist/config.d.ts.map +0 -1
  74. package/dist/config.js +0 -132
  75. package/dist/config.js.map +0 -1
  76. package/dist/conversation-viewer.d.ts +0 -40
  77. package/dist/conversation-viewer.d.ts.map +0 -1
  78. package/dist/conversation-viewer.js +0 -276
  79. package/dist/conversation-viewer.js.map +0 -1
  80. package/dist/core-compat.d.ts +0 -21
  81. package/dist/core-compat.d.ts.map +0 -1
  82. package/dist/core-compat.js +0 -86
  83. package/dist/core-compat.js.map +0 -1
  84. package/dist/custom-agents.d.ts +0 -14
  85. package/dist/custom-agents.d.ts.map +0 -1
  86. package/dist/custom-agents.js +0 -113
  87. package/dist/custom-agents.js.map +0 -1
  88. package/dist/file-lock.d.ts +0 -42
  89. package/dist/file-lock.d.ts.map +0 -1
  90. package/dist/file-lock.js +0 -91
  91. package/dist/file-lock.js.map +0 -1
  92. package/dist/index.d.ts +0 -10
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -758
  95. package/dist/index.js.map +0 -1
  96. package/dist/model-resolver.d.ts +0 -19
  97. package/dist/model-resolver.d.ts.map +0 -1
  98. package/dist/model-resolver.js +0 -61
  99. package/dist/model-resolver.js.map +0 -1
  100. package/dist/types.d.ts +0 -98
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -47
  103. package/dist/types.js.map +0 -1
  104. package/dist/widget.d.ts +0 -56
  105. package/dist/widget.d.ts.map +0 -1
  106. package/dist/widget.js +0 -396
  107. package/dist/widget.js.map +0 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @pi-unipi/subagents — File-system retry ladder
3
+ *
4
+ * Ported from pi-subagents src/shared/file-system-retry.ts (the blocking
5
+ * waitForFileSystemRetry primitive + ladder constants). Env override uses OUR
6
+ * prefix: UNIPI_SUBAGENT_FS_RETRY_MAX_TOTAL_MS.
7
+ */
8
+
9
+ const WAIT_BUFFER = typeof SharedArrayBuffer !== "undefined" ? new SharedArrayBuffer(4) : undefined;
10
+ const WAIT_VIEW = WAIT_BUFFER ? new Int32Array(WAIT_BUFFER) : undefined;
11
+
12
+ export const FS_RETRY_MAX_TOTAL_MS_ENV = "UNIPI_SUBAGENT_FS_RETRY_MAX_TOTAL_MS";
13
+ export const BASE_FILE_SYSTEM_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 500, 1000, 2000, 4000] as const;
14
+
15
+ export function resolveFileSystemRetryDelays(
16
+ env: NodeJS.ProcessEnv = process.env,
17
+ base: readonly number[] = BASE_FILE_SYSTEM_RETRY_DELAYS_MS,
18
+ ): readonly number[] {
19
+ const raw = env[FS_RETRY_MAX_TOTAL_MS_ENV];
20
+ if (raw === undefined || raw.trim() === "") return base;
21
+ const budget = Number(raw);
22
+ if (!Number.isInteger(budget) || budget < 0) {
23
+ throw new Error(`${FS_RETRY_MAX_TOTAL_MS_ENV} must be a non-negative integer number of milliseconds.`);
24
+ }
25
+ let spent = 0;
26
+ return base.map((delay) => {
27
+ const remaining = budget - spent;
28
+ spent += delay;
29
+ if (remaining <= 0) return 0;
30
+ return Math.min(delay, remaining);
31
+ });
32
+ }
33
+
34
+ export const DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS = resolveFileSystemRetryDelays();
35
+
36
+ export function waitForFileSystemRetry(delayMs: number): void {
37
+ if (delayMs <= 0) return;
38
+ if (WAIT_VIEW) {
39
+ try {
40
+ Atomics.wait(WAIT_VIEW, 0, 0, delayMs);
41
+ return;
42
+ } catch {
43
+ // fall through to setTimeout-free busy wait fallback below
44
+ }
45
+ }
46
+ const end = Date.now() + delayMs;
47
+ while (Date.now() < end) {
48
+ // Blocking sleep fallback for runtimes without Atomics.wait
49
+ }
50
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @pi-unipi/subagents — Async run summaries for the fleet panel
3
+ *
4
+ * Reads run dirs (status.json) into lightweight summaries. Ported essence of
5
+ * pi-subagents async-status.ts listAsyncRuns.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { readStatus } from "./async-runner.js";
11
+
12
+ export interface AsyncRunSummary {
13
+ runId: string;
14
+ runDir: string;
15
+ agent: string;
16
+ state: string;
17
+ startedAt: number;
18
+ taskSummary?: string;
19
+ }
20
+
21
+ const MAX_CANDIDATES = 100;
22
+
23
+ /** List async runs (any state), newest first. */
24
+ export function listAsyncRunSummaries(asyncDirRoot: string): AsyncRunSummary[] {
25
+ let entries: fs.Dirent[];
26
+ try {
27
+ entries = fs.readdirSync(asyncDirRoot, { withFileTypes: true });
28
+ } catch {
29
+ return [];
30
+ }
31
+
32
+ const summaries: AsyncRunSummary[] = [];
33
+ for (const entry of entries.slice(0, MAX_CANDIDATES)) {
34
+ if (!entry.isDirectory()) continue;
35
+ const runDir = path.join(asyncDirRoot, entry.name);
36
+ const status = readStatus(runDir);
37
+ if (!status.status) continue;
38
+ summaries.push({
39
+ runId: entry.name,
40
+ runDir,
41
+ agent: typeof status.agent === "string" ? status.agent : "unknown",
42
+ state: String(status.status),
43
+ startedAt:
44
+ typeof status.startedAt === "number"
45
+ ? status.startedAt
46
+ : typeof status.updatedAt === "number"
47
+ ? status.updatedAt
48
+ : statMtime(runDir),
49
+ ...(typeof status.task === "string" ? { taskSummary: status.task.slice(0, 120) } : {}),
50
+ });
51
+ }
52
+ return summaries.sort((left, right) => right.startedAt - left.startedAt);
53
+ }
54
+
55
+ function statMtime(dir: string): number {
56
+ try {
57
+ return fs.statSync(dir).mtimeMs;
58
+ } catch {
59
+ return Date.now();
60
+ }
61
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @pi-unipi/subagents — FleetView (persistent fleet panel)
3
+ *
4
+ * pi-subagents parity built on OUR panel system: a persistent setWidget slot
5
+ * (belowEditor/aboveEditor placement from config) summarizing active work —
6
+ * in-process agents from the AgentManager plus async process runs from the
7
+ * run dirs. Collapsed by default; ↓/← activates selection, j/k navigate,
8
+ * enter opens the inspector, esc deactivates.
9
+ */
10
+
11
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
12
+ import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
13
+ import type { AgentManager } from "./agent-manager.js";
14
+ import type { AgentActivity } from "./types.js";
15
+ import { listAsyncRunSummaries, type AsyncRunSummary } from "./fleet-data.js";
16
+
17
+ const REFRESH_MS = 500;
18
+ const MAX_AGENT_ROWS = 6;
19
+
20
+ export interface FleetEntry {
21
+ key: string;
22
+ source: "inprocess" | "async";
23
+ agent: string;
24
+ description?: string;
25
+ startedAt: number;
26
+ state: string;
27
+ /** Run dir for async entries (inspector target). */
28
+ runDir?: string;
29
+ }
30
+
31
+ export interface FleetViewOptions {
32
+ refreshMs?: number;
33
+ maxAgentRows?: number;
34
+ placement?: "belowEditor" | "aboveEditor";
35
+ /** Open the inspector for an entry (wired to ConversationViewer overlay). */
36
+ openInspector?: (entry: FleetEntry) => Promise<void> | void;
37
+ }
38
+
39
+ function isActiveState(state: string): boolean {
40
+ return state === "running" || state === "queued" || state === "pending";
41
+ }
42
+
43
+ /** Collect active entries across both transports. */
44
+ export function collectFleetEntries(
45
+ manager: AgentManager,
46
+ activity: Map<string, AgentActivity>,
47
+ asyncDirRoot: string,
48
+ ): FleetEntry[] {
49
+ const entries: FleetEntry[] = [];
50
+
51
+ for (const record of manager.listAgents()) {
52
+ if (!isActiveState(record.status)) continue;
53
+ const act = activity.get(record.id);
54
+ entries.push({
55
+ key: `inprocess:${record.id}`,
56
+ source: "inprocess",
57
+ agent: record.type,
58
+ description: record.description,
59
+ startedAt: record.startedAt,
60
+ state: record.status,
61
+ });
62
+ void act;
63
+ }
64
+
65
+ for (const run of listAsyncRunSummaries(asyncDirRoot)) {
66
+ if (!isActiveState(run.state)) continue;
67
+ entries.push({
68
+ key: `async:${run.runId}`,
69
+ source: "async",
70
+ agent: run.agent,
71
+ description: run.taskSummary,
72
+ startedAt: run.startedAt,
73
+ state: run.state,
74
+ runDir: run.runDir,
75
+ });
76
+ }
77
+
78
+ return entries.sort((left, right) => left.startedAt - right.startedAt || left.key.localeCompare(right.key));
79
+ }
80
+
81
+ export class FleetView {
82
+ private uiCtx: ExtensionUIContext | undefined;
83
+ private tui: { requestRender(): void } | undefined;
84
+ private timer: ReturnType<typeof setInterval> | undefined;
85
+ private widgetRegistered = false;
86
+ private lastRenderKey = "";
87
+ private entries: FleetEntry[] = [];
88
+ private active = false;
89
+ private selectedKey = "main";
90
+ private inspectorOpen = false;
91
+
92
+ constructor(
93
+ private manager: AgentManager,
94
+ private activity: Map<string, AgentActivity>,
95
+ private asyncDirRoot: string,
96
+ private options: FleetViewOptions = {},
97
+ ) {}
98
+
99
+ setUICtx(ctx: ExtensionUIContext): void {
100
+ if (ctx === this.uiCtx) return;
101
+ this.clearRegistration();
102
+ this.uiCtx = ctx;
103
+ this.ensureTimer();
104
+ this.refresh();
105
+ }
106
+
107
+ dispose(): void {
108
+ if (this.timer) clearInterval(this.timer);
109
+ this.timer = undefined;
110
+ this.clearRegistration();
111
+ this.uiCtx = undefined;
112
+ this.entries = [];
113
+ this.active = false;
114
+ this.selectedKey = "main";
115
+ }
116
+
117
+ /** Called when work starts/finishes to trigger a refresh. */
118
+ refresh(): void {
119
+ const ctx = this.uiCtx;
120
+ if (!ctx) return;
121
+ this.entries = collectFleetEntries(this.manager, this.activity, this.asyncDirRoot);
122
+ this.clampSelection();
123
+
124
+ if (this.inspectorOpen) {
125
+ this.lastRenderKey = "";
126
+ this.clearWidget();
127
+ return;
128
+ }
129
+ if (this.entries.length === 0) {
130
+ this.active = false;
131
+ this.selectedKey = "main";
132
+ this.lastRenderKey = "";
133
+ this.clearWidget();
134
+ return;
135
+ }
136
+
137
+ const renderKey = JSON.stringify({
138
+ active: this.active,
139
+ selected: this.selectedKey,
140
+ entries: this.entries.map((e) => [e.key, e.state]),
141
+ });
142
+ if (!this.widgetRegistered) {
143
+ ctx.setWidget(
144
+ "unipi-fleet-status",
145
+ (tui: unknown, _theme: unknown) => {
146
+ this.tui = tui as { requestRender(): void };
147
+ return {
148
+ render: (width: number) => this.render(width),
149
+ invalidate: () => {
150
+ this.lastRenderKey = "";
151
+ },
152
+ dispose: () => {
153
+ this.widgetRegistered = false;
154
+ this.tui = undefined;
155
+ },
156
+ };
157
+ },
158
+ { placement: this.options.placement ?? "belowEditor" },
159
+ );
160
+ this.widgetRegistered = true;
161
+ this.lastRenderKey = renderKey;
162
+ return;
163
+ }
164
+ if (renderKey === this.lastRenderKey) return;
165
+ this.lastRenderKey = renderKey;
166
+ this.tui?.requestRender();
167
+ }
168
+
169
+ /**
170
+ * Key handling for the collapsed/active roster. Returns consume intent.
171
+ * Wired from index.ts's terminal input hook.
172
+ */
173
+ handleKey(data: string, editorHasFocus: () => boolean): { consume?: boolean } | undefined {
174
+ const ctx = this.uiCtx;
175
+ if (!ctx || this.entries.length === 0) return undefined;
176
+ if (this.inspectorOpen) return undefined;
177
+ if (!editorHasFocus()) {
178
+ if (this.active) this.deactivate();
179
+ return undefined;
180
+ }
181
+
182
+ if (!this.active) {
183
+ const activates = matchesKey(data, "down") || matchesKey(data, "left");
184
+ if (!activates || ctx.getEditorText?.() !== "") return undefined;
185
+ this.active = true;
186
+ this.selectedKey = "main";
187
+ this.refresh();
188
+ return { consume: true };
189
+ }
190
+
191
+ const roster = this.rosterKeys();
192
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
193
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
194
+ this.selectedKey = roster[Math.min(roster.length - 1, selectedIndex + 1)] ?? "main";
195
+ this.refresh();
196
+ return { consume: true };
197
+ }
198
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
199
+ if (selectedIndex === 0) {
200
+ this.deactivate();
201
+ return { consume: true };
202
+ }
203
+ this.selectedKey = roster[selectedIndex - 1] ?? "main";
204
+ this.refresh();
205
+ return { consume: true };
206
+ }
207
+ if (matchesKey(data, "escape")) {
208
+ this.deactivate();
209
+ return { consume: true };
210
+ }
211
+ if (matchesKey(data, Key.enter)) {
212
+ if (this.selectedKey === "main") {
213
+ this.deactivate();
214
+ return { consume: true };
215
+ }
216
+ const entry = this.entries.find((e) => e.key === this.selectedKey);
217
+ if (!entry) return undefined;
218
+ this.inspectorOpen = true;
219
+ this.refresh();
220
+ void Promise.resolve()
221
+ .then(() => this.options.openInspector?.(entry))
222
+ .catch(() => {})
223
+ .finally(() => {
224
+ this.inspectorOpen = false;
225
+ this.refresh();
226
+ });
227
+ return { consume: true };
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ render(width: number): string[] {
233
+ if (this.entries.length === 0) return [];
234
+ if (!this.active) {
235
+ // Collapsed summary row.
236
+ const count = this.entries.length;
237
+ const noun = count === 1 ? "agent" : "agents";
238
+ const states = new Map<string, number>();
239
+ for (const entry of this.entries) states.set(entry.state, (states.get(entry.state) ?? 0) + 1);
240
+ const breakdown = [...states.entries()].map(([state, n]) => `${n} ${state}`).join(", ");
241
+ return [
242
+ truncateToWidth(` ${count} active ${noun} (${breakdown}) · ↓ to inspect`, width),
243
+ ];
244
+ }
245
+
246
+ const roster = this.rosterKeys();
247
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
248
+ const lines = [truncateToWidth(` ↑↓/jk select · enter inspect · esc back`, width), ""];
249
+ lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex)} main`, width));
250
+
251
+ const visibleCount = Math.min(this.options.maxAgentRows ?? MAX_AGENT_ROWS, this.entries.length);
252
+ const start = selectedIndex < visibleCount ? 0 : selectedIndex - visibleCount + 1;
253
+ const hiddenBelow = this.entries.length - (start + visibleCount);
254
+ if (start > 0) lines.push(truncateToWidth(` ↑ ${start} more`, width));
255
+ for (let index = start; index < start + visibleCount; index++) {
256
+ const entry = this.entries[index];
257
+ if (!entry) break;
258
+ lines.push(...this.renderEntry(index + 1, selectedIndex, entry, width));
259
+ }
260
+ if (hiddenBelow > 0) lines.push(truncateToWidth(` ↓ ${hiddenBelow} more`, width));
261
+ return lines;
262
+ }
263
+
264
+ private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetEntry, width: number): string[] {
265
+ void rosterIndex;
266
+ const elapsed = formatElapsed(Date.now() - entry.startedAt);
267
+ const sourceTag = entry.source === "async" ? "proc" : "local";
268
+ const left = ` ${this.bullet(-1, selectedIndex)} ${entry.agent} · ${entry.state}`;
269
+ const right = `${sourceTag} · ${elapsed}`;
270
+ return [truncateToWidth(`${left} ${right}`, width)];
271
+ }
272
+
273
+ private bullet(_index: number, selectedIndex: number): string {
274
+ return selectedIndex > 0 ? ">" : " ";
275
+ }
276
+
277
+ private rosterKeys(): string[] {
278
+ return ["main", ...this.entries.map((entry) => entry.key)];
279
+ }
280
+
281
+ private clampSelection(): void {
282
+ if (!this.rosterKeys().includes(this.selectedKey)) this.selectedKey = "main";
283
+ }
284
+
285
+ private deactivate(): void {
286
+ this.active = false;
287
+ this.selectedKey = "main";
288
+ this.refresh();
289
+ }
290
+
291
+ private clearWidget(): void {
292
+ if (this.widgetRegistered && this.uiCtx) {
293
+ this.uiCtx.setWidget("unipi-fleet-status", undefined);
294
+ this.widgetRegistered = false;
295
+ this.tui = undefined;
296
+ }
297
+ }
298
+
299
+ private clearRegistration(): void {
300
+ this.clearWidget();
301
+ }
302
+
303
+ private ensureTimer(): void {
304
+ if (this.timer) return;
305
+ this.timer = setInterval(() => this.refresh(), this.options.refreshMs ?? REFRESH_MS);
306
+ this.timer.unref?.();
307
+ }
308
+ }
309
+
310
+ function formatElapsed(ms: number): string {
311
+ const seconds = Math.floor(ms / 1000);
312
+ if (seconds < 60) return `${seconds}s`;
313
+ const minutes = Math.floor(seconds / 60);
314
+ if (minutes < 60) return `${minutes}m${seconds % 60}s`;
315
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
316
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @pi-unipi/subagents — Foreground detach shortcut
3
+ *
4
+ * Ported from pi-subagents foregroundDetachShortcut (config) +
5
+ * foregroundSingleHintText (render.ts). Optionally binds a shortcut that
6
+ * detaches the active foreground single-agent run WITHOUT terminating it:
7
+ * the parent stops waiting, the child keeps running, and the completion
8
+ * arrives later as a normal task-notification.
9
+ */
10
+
11
+ export interface DetachShortcutConfig {
12
+ /** Shortcut string like "ctrl+b". Empty/unset disables the feature. */
13
+ shortcut?: string;
14
+ }
15
+
16
+ /** Parse a shortcut string into normalized parts ("ctrl+b" → ["ctrl","b"]). */
17
+ export function parseDetachShortcut(raw: string | undefined): string[] | undefined {
18
+ const trimmed = raw?.trim().toLowerCase();
19
+ if (!trimmed) return undefined;
20
+ const parts = trimmed.split("+").map((part) => part.trim()).filter(Boolean);
21
+ if (parts.length === 0 || parts.some((part) => !part)) return undefined;
22
+ // Must include at least one modifier to avoid stealing plain keys.
23
+ const modifiers = new Set(["ctrl", "alt", "shift", "meta", "super"]);
24
+ if (!parts.slice(0, -1).some((part) => modifiers.has(part))) return undefined;
25
+ return parts;
26
+ }
27
+
28
+ /** Human-readable label ("ctrl+b" → "Ctrl+B"). */
29
+ export function formatDetachHint(parts: string[] | undefined): string | undefined {
30
+ if (!parts) return undefined;
31
+ const label = parts
32
+ .map((part) => {
33
+ if (part === "ctrl") return "Ctrl";
34
+ if (part === "alt") return "Alt";
35
+ if (part === "shift") return "Shift";
36
+ if (part === "escape") return "Esc";
37
+ if (part.length === 1) return part.toUpperCase();
38
+ return part.charAt(0).toUpperCase() + part.slice(1);
39
+ })
40
+ .join("+");
41
+ return `${label} detaches (run continues in background)`;
42
+ }
43
+
44
+ /**
45
+ * Whether a raw terminal input matches the configured shortcut.
46
+ * Matches pi-tui-style control sequences for ctrl+<letter> plus literal text
47
+ * for other combos.
48
+ */
49
+ export function matchesDetachInput(data: string, parts: string[] | undefined): boolean {
50
+ if (!parts) return false;
51
+ const hasCtrl = parts.includes("ctrl");
52
+ const key = parts[parts.length - 1]!;
53
+ if (hasCtrl && parts.length === 2 && key.length === 1) {
54
+ // Control sequence: C0 codes 0x01–0x1a map to ctrl+a–z.
55
+ const expected = String.fromCharCode(key.charCodeAt(0) - 96);
56
+ return data === expected;
57
+ }
58
+ return data === parts.join("+");
59
+ }