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/CHANGELOG.md +54 -0
- package/README.md +20 -0
- package/docs/CONTRACT.md +68 -17
- package/extensions/index.ts +33 -2
- package/package.json +3 -2
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +19 -3
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +197 -70
- package/src/core/spawn.ts +54 -14
- package/src/core/task.ts +25 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +3 -6
- package/src/tools/index.ts +50 -11
- package/src/tui/panel.ts +99 -38
- package/src/tui/rail.ts +73 -20
- package/src/tui/status.ts +23 -2
- package/src/tui/width.ts +53 -0
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
|
-
|
|
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
|
|
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: () =>
|
|
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
|
|
150
|
+
let streams: WatchOutput = { output: "", stdout: "", stderr: "" };
|
|
115
151
|
let exitCode: number | undefined;
|
|
116
152
|
try {
|
|
117
|
-
if (needsOutput)
|
|
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 = {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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
|
-
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
|
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: {
|
|
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",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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({
|
|
91
|
-
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
66
|
-
clearDoneStatus();
|
|
73
|
+
clearDoneStatus(); // shared watermark in status.ts (#31)
|
|
67
74
|
},
|
|
68
75
|
});
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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 =
|
|
136
|
+
const tasks = filtered();
|
|
107
137
|
if (!detail) {
|
|
108
|
-
|
|
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} · ${
|
|
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: ${
|
|
122
|
-
|
|
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),
|
|
125
|
-
|
|
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 =
|
|
179
|
+
const tasks = filtered();
|
|
139
180
|
const index = tasks.findIndex((task) => task.taskId === selected);
|
|
140
|
-
|
|
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
|
|
154
|
-
if (key === "h" && !detail) {
|
|
155
|
-
|
|
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 {
|
|
168
|
-
|
|
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
|
|
193
|
-
const
|
|
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
|
|
201
|
-
const
|
|
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 };
|