pi-async 0.1.0 → 0.2.0

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/src/core/watch.ts CHANGED
@@ -2,6 +2,35 @@ import type { Condition, WatchSpec } from "./task.ts";
2
2
 
3
3
  const compiledRegexes = new WeakMap<Condition, RegExp>();
4
4
 
5
+ /** Captured streams for watch evaluation: stdout/stderr are distinct; output is merged. */
6
+ export interface WatchOutput {
7
+ output: string;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+
12
+ /** Cap regex subject size so catastrophic patterns cannot grow without bound (#24). */
13
+ export const MAX_OUTPUT_MATCH_BYTES = 64 * 1024;
14
+
15
+ /**
16
+ * Classic exponential-backtracking shape: a group that itself ends with a
17
+ * quantifier, then another quantifier — e.g. (a+)+ / (a*)* / (a|b+)+.
18
+ * Conservative; does not catch every polynomial case.
19
+ */
20
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*]\)\s*[+*]/;
21
+
22
+ function rejectDangerousRegex(regex: string): string | undefined {
23
+ if (NESTED_QUANTIFIER.test(regex)) {
24
+ return "regex has nested quantifiers (ReDoS risk); simplify or use stdout_contains";
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ function matchSubject(output: string): string {
30
+ if (output.length <= MAX_OUTPUT_MATCH_BYTES) return output;
31
+ return output.slice(-MAX_OUTPUT_MATCH_BYTES);
32
+ }
33
+
5
34
  export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false; error: string } {
6
35
  const invalid = (error: string) => ({ ok: false as const, error });
7
36
  if (!spec || typeof spec !== "object") return invalid("watch must be an object");
@@ -38,13 +67,18 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
38
67
  break;
39
68
  case "json_path_exists":
40
69
  if (typeof condition.path !== "string" || parseJsonPath(condition.path) === undefined) {
41
- return invalid(`${location}: invalid JSON path`);
70
+ const shown = typeof condition.path === "string" ? JSON.stringify(condition.path) : String(condition.path);
71
+ return invalid(`${location}: invalid JSON path ${shown}: paths must start with "$" (e.g. $.ready or $.items[0].state)`);
42
72
  }
43
73
  break;
44
74
  case "output_matches":
45
75
  if (typeof condition.regex !== "string") return invalid(`${location}: regex must be a string`);
76
+ {
77
+ const danger = rejectDangerousRegex(condition.regex);
78
+ if (danger) return invalid(`${location}: ${danger}`);
79
+ }
46
80
  try {
47
- compiledRegexes.set(condition, new RegExp(condition.regex));
81
+ compiledRegexes.set(condition, new RegExp(condition.regex, "m"));
48
82
  } catch {
49
83
  return invalid(`${location}: invalid regex`);
50
84
  }
@@ -57,7 +91,9 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
57
91
  return { ok: true };
58
92
  }
59
93
 
60
- export function evalConditions(conds: Condition[], ctx: { output: string; exitCode?: number }): boolean {
94
+ export type WatchEvalContext = WatchOutput & { exitCode?: number };
95
+
96
+ export function evalConditions(conds: Condition[], ctx: WatchEvalContext): boolean {
61
97
  const result = evaluateConditionsDetailed(conds, ctx);
62
98
  return result.status === "matched" && result.matched;
63
99
  }
@@ -65,7 +101,7 @@ export function evalConditions(conds: Condition[], ctx: { output: string; exitCo
65
101
  export interface WatchLoopDeps {
66
102
  taskId: string;
67
103
  spec: WatchSpec;
68
- readOutput: () => string;
104
+ readOutput: () => WatchOutput;
69
105
  getExitCode: () => number | undefined;
70
106
  onSuccess: (detail: string) => void;
71
107
  onFailure: () => void;
@@ -111,17 +147,17 @@ export class WatchLoop {
111
147
  const conditions = [...this.deps.spec.failure_when, ...this.deps.spec.success_when];
112
148
  const needsOutput = conditions.some((condition) => condition.type !== "exit_code");
113
149
  const needsExitCode = conditions.some((condition) => condition.type === "exit_code");
114
- let output = "";
150
+ let streams: WatchOutput = { output: "", stdout: "", stderr: "" };
115
151
  let exitCode: number | undefined;
116
152
  try {
117
- if (needsOutput) output = this.deps.readOutput();
153
+ if (needsOutput) streams = this.deps.readOutput();
118
154
  if (needsExitCode) exitCode = this.deps.getExitCode();
119
155
  } catch {
120
156
  this.countBlindCheck();
121
157
  return;
122
158
  }
123
159
 
124
- const ctx = { output, exitCode };
160
+ const ctx: WatchEvalContext = { ...streams, exitCode };
125
161
  const failure = evaluateConditionsDetailed(this.deps.spec.failure_when, ctx);
126
162
  if (failure.status === "matched" && failure.matched) {
127
163
  this.finish(this.deps.onFailure);
@@ -178,7 +214,7 @@ type ConditionEvaluation =
178
214
  | { status: "pending" }
179
215
  | { status: "error" };
180
216
 
181
- function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: string; exitCode?: number }): ConditionEvaluation {
217
+ function evaluateConditionsDetailed(conditions: Condition[], ctx: WatchEvalContext): ConditionEvaluation {
182
218
  let pending = false;
183
219
  let error = false;
184
220
  for (const condition of conditions) {
@@ -192,21 +228,21 @@ function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: stri
192
228
  return { status: "matched", matched: false };
193
229
  }
194
230
 
195
- function evaluateCondition(condition: Condition, ctx: { output: string; exitCode?: number }): ConditionEvaluation {
231
+ function evaluateCondition(condition: Condition, ctx: WatchEvalContext): ConditionEvaluation {
196
232
  switch (condition.type) {
197
233
  case "exit_code":
198
234
  return ctx.exitCode === undefined
199
235
  ? { status: "pending" }
200
236
  : { status: "matched", matched: ctx.exitCode === condition.equals };
201
237
  case "stdout_contains":
238
+ return { status: "matched", matched: ctx.stdout.includes(condition.value) };
202
239
  case "stderr_contains":
203
- // The frozen WatchLoop context exposes the captured merged output stream.
204
- return { status: "matched", matched: ctx.output.includes(condition.value) };
240
+ return { status: "matched", matched: ctx.stderr.includes(condition.value) };
205
241
  case "output_matches": {
206
242
  let regex = compiledRegexes.get(condition);
207
243
  if (!regex) {
208
244
  try {
209
- regex = new RegExp(condition.regex);
245
+ regex = new RegExp(condition.regex, "m");
210
246
  compiledRegexes.set(condition, regex);
211
247
  } catch {
212
248
  return { status: "error" };
@@ -214,7 +250,7 @@ function evaluateCondition(condition: Condition, ctx: { output: string; exitCode
214
250
  }
215
251
  // Global/sticky expressions otherwise retain lastIndex between watch polls.
216
252
  regex.lastIndex = 0;
217
- return { status: "matched", matched: regex.test(ctx.output) };
253
+ return { status: "matched", matched: regex.test(matchSubject(ctx.output)) };
218
254
  }
219
255
  case "json_path_exists": {
220
256
  let root: unknown;
@@ -1,5 +1,6 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
+ import { envNumber } from "../core/env.ts";
3
4
 
4
5
  export class SettleGuard {
5
6
  private readonly maxReminders: number;
@@ -14,7 +15,7 @@ export class SettleGuard {
14
15
  this.pi = pi;
15
16
  this.registry = registry;
16
17
  this.maxReminders = opts?.maxReminders ?? 3;
17
- this.heartbeatMs = opts?.heartbeatMs ?? finiteEnvNumber("PI_ASYNC_HEARTBEAT_MS", 60_000);
18
+ this.heartbeatMs = opts?.heartbeatMs ?? envNumber("PI_ASYNC_HEARTBEAT_MS", 60_000, { integer: true, min: 0 });
18
19
  }
19
20
 
20
21
  register(): void {
@@ -59,7 +60,7 @@ export class SettleGuard {
59
60
  if (typeof ui?.setActivity !== "function") return;
60
61
  const count = this.registry.activeCount();
61
62
  const total = count.running + count.pending;
62
- ui.setActivity("pi-async", total ? { message: `${total} async tasks` } : undefined);
63
+ ui.setActivity("pi-async", total ? { message: `${total} async task${total === 1 ? "" : "s"}` } : undefined);
63
64
  }
64
65
 
65
66
  private startHeartbeat(hooks: { appendEntry?: (customType: string, data: unknown) => void }): void {
@@ -72,7 +73,3 @@ export class SettleGuard {
72
73
  }
73
74
  }
74
75
 
75
- function finiteEnvNumber(name: string, fallback: number): number {
76
- const parsed = Number(process.env[name] ?? fallback);
77
- return Number.isFinite(parsed) ? parsed : fallback;
78
- }
@@ -1,6 +1,6 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
- import type { WatchSpec } from "../core/task.ts";
3
+ import { isTerminal, type WatchSpec } from "../core/task.ts";
4
4
 
5
5
  /**
6
6
  * Wrap an extension-tool return value into pi's ToolResultMessage shape.
@@ -21,7 +21,7 @@ function toolResult(payload: unknown, isError = false): { content: Array<{ type:
21
21
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], details: { payload }, isError };
22
22
  }
23
23
 
24
- const promptGuidelines = "Check commands must not swallow errors with || true; map unknown states to failure; prefer structured output and jq -e; anchor output_matches regexes to avoid premature matches.";
24
+ const promptGuidelines = "Check commands must not swallow errors with || true; map unknown states to failure; prefer structured output and jq -e; anchor output_matches regexes (compiled with multiline `m`: ^/$ are line boundaries) to avoid premature matches; prefer stdout_contains for simple cases and avoid nested quantifiers.";
25
25
  const MAX_LOG_BYTES = 50 * 1024;
26
26
  const schema = (properties: Record<string, unknown>, required: string[] = [], allOf: unknown[] = []) => ({ type: "object", properties, required, additionalProperties: false, ...(allOf.length ? { allOf } : {}) });
27
27
  const stringField = { type: "string" };
@@ -30,7 +30,10 @@ const conditionSchema = {
30
30
  oneOf: [
31
31
  { type: "object", properties: { type: { const: "exit_code" }, equals: { type: "number" } }, required: ["type", "equals"], additionalProperties: false },
32
32
  ...["stdout_contains", "stderr_contains"].map((type) => ({ type: "object", properties: { type: { const: type }, value: stringField }, required: ["type", "value"], additionalProperties: false })),
33
- { type: "object", properties: { type: { const: "json_path_exists" }, path: stringField }, required: ["type", "path"], additionalProperties: false },
33
+ { type: "object", properties: {
34
+ type: { const: "json_path_exists" },
35
+ path: { type: "string", pattern: "^\\$", description: "JSONPath rooted at $; e.g. $.ready or $.items[0].state (bare names like \"ready\" are rejected)" },
36
+ }, required: ["type", "path"], additionalProperties: false },
34
37
  { type: "object", properties: { type: { const: "output_matches" }, regex: stringField }, required: ["type", "regex"], additionalProperties: false },
35
38
  ],
36
39
  };
@@ -49,12 +52,11 @@ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
49
52
  const api = pi as unknown as { registerTool?: (tool: ToolDef) => void };
50
53
  if (!api.registerTool) throw new Error("Pi host does not expose registerTool");
51
54
  api.registerTool({
52
- name: "async_run", label: "Run async command", description: `Run a background, delayed, or synchronous shell command. ${promptGuidelines}`,
53
- parameters: schema({ command: stringField, name: stringField, mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" }, timeoutMs: timeoutField, delayMs: timeoutField, taskTimeoutMs: timeoutField, onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" }, notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" }, env: { type: "object", additionalProperties: { type: "string" } }, watch: watchSchema }, ["command"], [
54
- { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { required: ["timeoutMs"] } },
55
- { if: { properties: { mode: { const: "delayed" } }, required: ["mode"] }, then: { required: ["delayMs"] } },
56
- { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { not: { required: ["watch"] } } },
57
- ]),
55
+ name: "async_run", label: "Run async command",
56
+ description: `Run a background, delayed, or synchronous shell command. mode=sync requires timeoutMs; mode=delayed requires delayMs. onTimeout (sync only): "promote" (default) hands the still-running task to background and returns; "abort" stops WAITING only — the command keeps running in the background and will still notify on completion (Esc semantics; does not kill). ${promptGuidelines}`,
57
+ // Mode-conditional required fields + mode-scoped rejects enforced in execute (#45/#42),
58
+ // not allOf/then (host validator emits opaque "must match then schema").
59
+ parameters: schema({ command: stringField, name: stringField, mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" }, timeoutMs: timeoutField, delayMs: timeoutField, taskTimeoutMs: timeoutField, onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" }, notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" }, env: { type: "object", additionalProperties: { type: "string" } }, watch: watchSchema }, ["command"]),
58
60
  execute: async (_id, params, signal) => {
59
61
  const mode = String(params.mode ?? "background");
60
62
  if (mode !== "background" && mode !== "sync" && mode !== "delayed") throw new Error(`Unsupported async_run mode: ${mode}`);
@@ -62,6 +64,8 @@ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
62
64
  if (mode === "sync" && params.timeoutMs === undefined) throw new Error("timeoutMs is required for sync mode");
63
65
  if (mode === "delayed" && params.delayMs === undefined) throw new Error("delayMs is required for delayed mode");
64
66
  if (params.watch !== undefined && mode === "sync") throw new Error("watch is only supported for background and delayed modes");
67
+ if (mode !== "sync" && params.timeoutMs !== undefined) throw new Error("timeoutMs is only valid for sync mode (use taskTimeoutMs to bound background/delayed tasks)");
68
+ if (mode !== "delayed" && params.delayMs !== undefined) throw new Error("delayMs is only valid for delayed mode");
65
69
  const options = {
66
70
  command,
67
71
  ...(typeof params.name === "string" ? { name: params.name } : {}),
@@ -87,6 +91,41 @@ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
87
91
  if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
88
92
  return toolResult(registry.logs(String(params.taskId), { maxBytes, tail: params.tail !== false }));
89
93
  } });
90
- api.registerTool({ name: "async_status", label: "Async task status", description: "List async tasks and current status.", parameters: schema({ includeHistory: { type: "boolean" } }), execute: async (_id, params) => toolResult(registry.list({ includeHistory: params.includeHistory !== false })) });
91
- api.registerTool({ name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.", parameters: schema({ taskId: stringField }, ["taskId"]), execute: async (_id, params) => { await registry.kill(String(params.taskId), "user"); return toolResult({ taskId: params.taskId, status: registry.get(String(params.taskId))?.status }); } });
94
+ api.registerTool({
95
+ name: "async_status", label: "Async task status",
96
+ description: "List async tasks and current status. Defaults to active (running/pending) only; pass includeHistory:true for up to 50 recent tasks.",
97
+ parameters: schema({ includeHistory: { type: "boolean" } }),
98
+ execute: async (_id, params) => {
99
+ const MAX_HISTORY = 50;
100
+ if (params.includeHistory === true) {
101
+ const all = registry.list({ includeHistory: true });
102
+ return toolResult(all.slice(-MAX_HISTORY));
103
+ }
104
+ return toolResult(registry.list({ includeHistory: false }));
105
+ },
106
+ });
107
+ api.registerTool({
108
+ name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.",
109
+ parameters: schema({ taskId: stringField }, ["taskId"]),
110
+ execute: async (_id, params) => {
111
+ const taskId = String(params.taskId);
112
+ const before = registry.get(taskId);
113
+ const alreadyTerminal = before !== undefined && isTerminal(before.status);
114
+ let warning: string | undefined;
115
+ try {
116
+ await registry.kill(taskId, "user");
117
+ } catch (error) {
118
+ // Kill may still have finalized the task; surface confirmation failures as warning (#6).
119
+ warning = error instanceof Error ? error.message : String(error);
120
+ }
121
+ const after = registry.get(taskId);
122
+ return toolResult({
123
+ taskId,
124
+ status: after?.status,
125
+ ...(after?.killKind ? { killKind: after.killKind } : {}),
126
+ alreadyTerminal,
127
+ ...(warning ? { warning } : {}),
128
+ }, Boolean(warning && after?.status !== "killed"));
129
+ },
130
+ });
92
131
  }
package/src/tui/panel.ts CHANGED
@@ -2,8 +2,10 @@ import { openSync, closeSync, fstatSync, readSync } from "node:fs";
2
2
  import type { PiCtx } from "../core/notify.ts";
3
3
  import type { TaskRegistry } from "../core/registry.ts";
4
4
  import type { TaskSnapshot } from "../core/task.ts";
5
- import { stripAnsi } from "../core/output.ts";
6
- import { clearDoneStatus } from "./status.ts";
5
+ import { stripAnsi, visibleText } from "../core/output.ts";
6
+ import { formatDuration } from "../core/duration.ts";
7
+ import { fitWidth } from "./width.ts";
8
+ import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
7
9
  import { tuiHost } from "./host.ts";
8
10
 
9
11
  const isTui = (pi: unknown): boolean => {
@@ -14,8 +16,9 @@ const isTui = (pi: unknown): boolean => {
14
16
 
15
17
  const UI_TAIL_BYTES = 128 * 1024;
16
18
  const PANEL_ROWS = 12;
17
- let historyClearedAt = 0;
18
- const visible = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\r\n\t]+/g, " ");
19
+ const visible = visibleText;
20
+ /** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
21
+ const visibleLine = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\t]+/g, " ");
19
22
  function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
20
23
  const outputPath = task.metadata.outputFile;
21
24
  if (typeof outputPath !== "string") return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
@@ -33,21 +36,26 @@ function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: strin
33
36
  }
34
37
  }
35
38
 
36
- const duration = (task: TaskSnapshot, now: number): string => {
37
- const end = task.finishedAt ?? now;
38
- const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
39
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
40
- };
41
39
  const frame = (title: string, body: string[], width: number, height: number): string[] => {
42
40
  const w = Math.max(8, Math.floor(width));
43
41
  const h = Math.max(4, Math.floor(height));
44
- const fit = (text: string): string => Array.from(text).slice(0, w - 4).join("").padEnd(w - 4);
45
- const content = [`╭─ ${fit(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
42
+ // Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
43
+ // #55 keeps fitTitle(w-5); #7/#62 routes both through fitWidth (cell-aware).
44
+ const fitTitle = (text: string): string => fitWidth(text, w - 5);
45
+ const fit = (text: string): string => fitWidth(text, w - 4);
46
+ const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
46
47
  while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
47
48
  // The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
48
49
  return content;
49
50
  };
50
51
 
52
+ /** Bare Esc, Kitty CSI-u Escape (key 27), and a lone CSI introducer. */
53
+ export function isEscapeKey(data: string): boolean {
54
+ if (data === "\u001b") return true;
55
+ if (data === "\u001b[") return true;
56
+ return /^\u001b\[27(?:;\d+)*u$/.test(data);
57
+ }
58
+
51
59
  export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
52
60
  // Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
53
61
  // gating comes from the bound event context. Handlers receive a rich ctx with ui.
@@ -62,14 +70,23 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
62
70
  description: "Clear async task history",
63
71
  handler: (_args, ctx) => {
64
72
  if (!isTui(ctx)) return;
65
- historyClearedAt = Date.now();
66
- clearDoneStatus();
73
+ clearDoneStatus(); // shared watermark in status.ts (#31)
67
74
  },
68
75
  });
69
- try {
70
- host.registerShortcut?.(process.env.PI_ASYNC_DOCK_SHORTCUT ?? "ctrl+alt+a", { description: "Open async task panel", handler: (ctx) => openPanel(ctx, registry) });
71
- } catch {
72
- // The /async command remains the fallback when Pi reserves this shortcut.
76
+ // CONTRACT: PI_ASYNC_DOCK_SHORTCUT is disableable ("可 off"). Treat
77
+ // off/none/false/empty (case-insensitive, trimmed) as skip registration —
78
+ // otherwise "off" would be registered as a literal key name (#30).
79
+ // Unset stays the default; only an explicitly set disable value skips.
80
+ const rawShortcut = process.env.PI_ASYNC_DOCK_SHORTCUT;
81
+ const normalized = rawShortcut === undefined ? undefined : rawShortcut.trim().toLowerCase();
82
+ const disabled = normalized !== undefined && ["off", "none", "false", ""].includes(normalized);
83
+ if (!disabled) {
84
+ const key = rawShortcut && rawShortcut.trim() ? rawShortcut.trim() : "ctrl+alt+a";
85
+ try {
86
+ host.registerShortcut?.(key, { description: "Open async task panel", handler: (ctx) => openPanel(ctx, registry) });
87
+ } catch {
88
+ // The /async command remains the fallback when Pi reserves this shortcut.
89
+ }
73
90
  }
74
91
  }
75
92
 
@@ -86,7 +103,7 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
86
103
  } | undefined,
87
104
  };
88
105
  if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
89
- const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => task.createdAt >= historyClearedAt || task.status === "running" || task.status === "pending");
106
+ const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
90
107
  const custom = host.ui.custom;
91
108
  const factory = (tui: unknown, _theme: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
92
109
  let selected = initialTaskId ?? snapshots()[0]?.taskId;
@@ -94,21 +111,37 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
94
111
  let history = true;
95
112
  let confirmKill = false;
96
113
  let scroll = 0;
114
+ let listScroll = 0;
97
115
  let follow = true;
98
116
  let width = 100;
99
117
  let height = process.stdout.rows ?? 24;
100
118
  let lastLines: string[] = [];
119
+ let actionError: string | undefined;
101
120
  const requestRender = (): void => {
102
121
  (tui as { requestRender?: () => void } | undefined)?.requestRender?.();
103
122
  };
123
+ const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
104
124
  const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
125
+ const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
126
+ const ensureListWindow = (tasks: TaskSnapshot[]): number => {
127
+ const budget = listBudget();
128
+ const selIndex = tasks.findIndex((task) => task.taskId === selected);
129
+ const index = selIndex < 0 ? 0 : selIndex;
130
+ if (index < listScroll) listScroll = index;
131
+ if (index >= listScroll + budget) listScroll = index - budget + 1;
132
+ listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
133
+ return listScroll;
134
+ };
105
135
  const renderBody = (): string[] => {
106
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
136
+ const tasks = filtered();
107
137
  if (!detail) {
108
- return ["↑/↓ select · Enter details · h history · q close", ...tasks.map((task) => {
138
+ const start = ensureListWindow(tasks);
139
+ const budget = listBudget();
140
+ const rows = tasks.slice(start, start + budget).map((task) => {
109
141
  const output = task.status === "running" ? registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text.split(/\r?\n/).filter(Boolean).at(-1) : undefined;
110
- return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${duration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
111
- })];
142
+ return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
143
+ });
144
+ return ["↑/↓ select · Enter details · h history · q close", ...rows];
112
145
  }
113
146
  const task = choose();
114
147
  if (!task) return ["Task not found", "Esc return · q close"];
@@ -117,12 +150,20 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
117
150
  scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
118
151
  const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
119
152
  const meta = JSON.stringify(task.metadata);
153
+ const commandLines = String(task.command ?? "").split(/\r?\n/);
154
+ const killHint = task.status === "pending"
155
+ ? "Cancel this pending task? press k again to confirm; any other key cancels"
156
+ : "Kill this task? press k again to confirm; any other key cancels";
120
157
  return [
121
- `${visible(task.name)} [${task.taskId}]`, `status: ${task.status} · elapsed: ${duration(task, Date.now())}`,
122
- `command: ${visible(task.command)}`, `metadata: ${meta}`, `output${output.truncated ? " (tail truncated)" : ""}:`,
158
+ `${visible(task.name)} [${task.taskId}]`, `status: ${task.status} · elapsed: ${formatDuration(task, Date.now())}`,
159
+ "command:",
160
+ ...commandLines.map((line) => ` ${visibleLine(line)}`),
161
+ `metadata: ${meta}`, `output${output.truncated ? " (tail truncated)" : ""}:`,
123
162
  // registry.logs caps its own data at 50 KiB; the TUI intentionally reads a larger 128 KiB tail directly from the output file.
124
- ...tail.map(visible), ...(confirmKill ? ["Kill this task? press k again to confirm; any other key cancels"] : []),
125
- "Esc return · k kill · R rerun · c copy output path · ↑/↓ scroll",
163
+ ...tail.map(visible),
164
+ ...(actionError ? [`error: ${visible(actionError)}`] : []),
165
+ ...(confirmKill ? [killHint] : []),
166
+ "Esc return · k kill/cancel · R rerun · c copy output path · ↑/↓ scroll",
126
167
  ];
127
168
  };
128
169
  const render = (nextWidth?: number): string[] => {
@@ -135,9 +176,11 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
135
176
  return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
136
177
  };
137
178
  const move = (delta: number): void => {
138
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
179
+ const tasks = filtered();
139
180
  const index = tasks.findIndex((task) => task.taskId === selected);
140
- const next = tasks[Math.max(0, Math.min(tasks.length - 1, index + delta))];
181
+ // Harden dangling selection (filtered away): start from edge rather than relying on -1+delta===0 alone.
182
+ const base = index < 0 ? (delta > 0 ? -1 : 0) : index;
183
+ const next = tasks[Math.max(0, Math.min(tasks.length - 1, base + delta))];
141
184
  if (next) selected = next.taskId;
142
185
  if (detail && delta < 0) { scroll += -delta; follow = false; }
143
186
  else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
@@ -150,22 +193,37 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
150
193
  const key = data.toLowerCase();
151
194
  if (confirmKill && key !== "k") { confirmKill = false; requestRender(); return; }
152
195
  if (key === "q") { done(undefined); return; }
153
- if (data === "\u001b") { if (detail) { detail = false; confirmKill = false; requestRender(); } else done(undefined); return; }
154
- if (key === "h" && !detail) { history = !history; requestRender(); return; }
155
- if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; requestRender(); return; }
196
+ if (isEscapeKey(data)) { if (detail) { detail = false; confirmKill = false; actionError = undefined; requestRender(); } else done(undefined); return; }
197
+ if (key === "h" && !detail) {
198
+ history = !history;
199
+ const tasks = filtered();
200
+ if (!tasks.some((task) => task.taskId === selected)) selected = tasks[0]?.taskId;
201
+ listScroll = 0;
202
+ requestRender();
203
+ return;
204
+ }
205
+ if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; actionError = undefined; requestRender(); return; }
156
206
  if (data === "\u001b[A" || data === "k") { if (detail && key === "k") {
157
207
  if (!confirmKill) { confirmKill = true; requestRender(); return; }
158
208
  confirmKill = false;
159
209
  const task = choose();
160
- if (task?.status === "running") void registry.kill(task.taskId, "user");
210
+ if (task?.status === "running" || task?.status === "pending") void registry.kill(task.taskId, "user");
161
211
  requestRender(); return;
162
212
  } move(-1); return; }
163
213
  if (data === "\u001b[B" || key === "j") { move(1); return; }
164
214
  if (data === "R" && detail) {
165
215
  const task = choose();
166
216
  if (task) {
167
- try { registry.submit({ command: task.command, name: task.name, mode: "background" }); }
168
- catch (error) { host.ui?.notify?.(String(error), "error"); }
217
+ try {
218
+ const created = registry.submit({ command: task.command, name: task.name, mode: "background" });
219
+ selected = created.taskId;
220
+ scroll = 0;
221
+ follow = true;
222
+ actionError = undefined;
223
+ } catch (error) {
224
+ actionError = String(error);
225
+ host.ui?.notify?.(actionError, "error");
226
+ }
169
227
  }
170
228
  requestRender(); return;
171
229
  }
@@ -189,22 +247,25 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
189
247
  latchedId = undefined;
190
248
  if (detail) return undefined;
191
249
  if (event.button !== "left") return undefined;
192
- const index = (event.y ?? -1) - 2;
193
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
250
+ const tasks = filtered();
251
+ const start = ensureListWindow(tasks);
252
+ const index = (event.y ?? -1) - 2 + start;
194
253
  const task = tasks[index];
195
254
  if (!task) return undefined;
196
255
  latchedId = task.taskId;
197
256
  return { handled: true };
198
257
  }
199
258
  if (event.type !== "click" || event.button !== "left" || detail) return undefined;
200
- const index = (event.y ?? -1) - 2;
201
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
259
+ const tasks = filtered();
260
+ const start = ensureListWindow(tasks);
261
+ const index = (event.y ?? -1) - 2 + start;
202
262
  const task = tasks[index];
203
263
  if (!latchedId || !task || task.taskId !== latchedId) { latchedId = undefined; return undefined; }
204
264
  selected = latchedId;
205
265
  detail = true;
206
266
  scroll = 0;
207
267
  follow = true;
268
+ actionError = undefined;
208
269
  latchedId = undefined;
209
270
  requestRender();
210
271
  return { handled: true };