@mjasnikovs/pi-task 0.18.24 → 0.18.26

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.
@@ -42,7 +42,36 @@ export interface PiTaskConfig {
42
42
  * /task-config, which enumerates the currently installed extensions.
43
43
  */
44
44
  extensionWhitelist: string[];
45
+ /**
46
+ * Wall-clock ceiling (ms) on a SINGLE tool execution in the MAIN session
47
+ * before the command watchdog cancels it and reminds the model to set its
48
+ * own timeout. Local models routinely run a command that never returns
49
+ * (e.g. `godot --headless` with no timeout, a dev server, a hung test) and
50
+ * the run wedges until the user manually aborts. pi's bash tool has an
51
+ * OPTIONAL timeout with NO default (bash.js), so a command the model didn't
52
+ * bound runs forever; this is the missing default, enforced from the host
53
+ * side via ctx.abort() (which kills the tool's whole process tree) plus an
54
+ * auto-reminder turn. Tool-agnostic: it arms on any tool execution, though
55
+ * in practice only bash runs long enough to trip it. 0 = off.
56
+ * DEFAULT 15 min: long enough for a real build/test suite, short enough that
57
+ * a true hang doesn't cost half an hour of dead time.
58
+ */
59
+ requestTimeoutMs: number;
45
60
  }
61
+ /**
62
+ * The command-watchdog timeout choices offered by /task-config, newest-first in
63
+ * the cycle order the picker shows. The stored config value is the ms number;
64
+ * the label is display-only (mirrors the searchProvider label/value split).
65
+ */
66
+ export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
67
+ label: string;
68
+ ms: number;
69
+ }>;
70
+ /**
71
+ * A hand-edited or stale config could carry any number (or a string); pin it to
72
+ * one of the offered choices so the watchdog never arms on a nonsense value.
73
+ */
74
+ export declare function sanitizeRequestTimeoutMs(value: unknown): number;
46
75
  /**
47
76
  * A hand-edited config can hold anything; keep only string entries so a stray
48
77
  * object/number can't reach the child argv as `-e [object Object]`.
@@ -3,6 +3,28 @@ import * as fsp from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { isSearchProvider } from '../workers/search-types.js';
6
+ /**
7
+ * The command-watchdog timeout choices offered by /task-config, newest-first in
8
+ * the cycle order the picker shows. The stored config value is the ms number;
9
+ * the label is display-only (mirrors the searchProvider label/value split).
10
+ */
11
+ export const COMMAND_TIMEOUT_OPTIONS = [
12
+ { label: '5 min', ms: 5 * 60_000 },
13
+ { label: '10 min', ms: 10 * 60_000 },
14
+ { label: '15 min', ms: 15 * 60_000 },
15
+ { label: '30 min', ms: 30 * 60_000 },
16
+ { label: 'off', ms: 0 }
17
+ ];
18
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15 * 60_000;
19
+ /**
20
+ * A hand-edited or stale config could carry any number (or a string); pin it to
21
+ * one of the offered choices so the watchdog never arms on a nonsense value.
22
+ */
23
+ export function sanitizeRequestTimeoutMs(value) {
24
+ return COMMAND_TIMEOUT_OPTIONS.some(o => o.ms === value) ?
25
+ value
26
+ : DEFAULT_REQUEST_TIMEOUT_MS;
27
+ }
6
28
  const DEFAULTS = {
7
29
  remote: true,
8
30
  compressReasoning: true,
@@ -15,7 +37,8 @@ const DEFAULTS = {
15
37
  // 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
16
38
  researchCache: true,
17
39
  searchProvider: 'exa',
18
- extensionWhitelist: []
40
+ extensionWhitelist: [],
41
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
19
42
  };
20
43
  /**
21
44
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -43,6 +66,7 @@ if (!G.loaded) {
43
66
  if (!isSearchProvider(parsed.searchProvider))
44
67
  delete parsed.searchProvider;
45
68
  parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
69
+ parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
46
70
  G.config = { ...DEFAULTS, ...parsed };
47
71
  }
48
72
  catch {
@@ -1,7 +1,7 @@
1
1
  import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
3
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
4
- import { getConfig, saveConfig } from './config.js';
4
+ import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig } from './config.js';
5
5
  import { listInstalledExtensions } from './extension-list.js';
6
6
  const CONFIG_TITLE = 'pi-task settings';
7
7
  /**
@@ -95,12 +95,28 @@ const ITEMS = [
95
95
  description: 'Engine behind web search (pi-worker-search + freshness checks). Exa and DuckDuckGo need no API key; Brave needs BRAVE_SEARCH_API_KEY',
96
96
  // Display full engine names; the stored config value stays the short id.
97
97
  values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p])
98
+ },
99
+ {
100
+ id: 'requestTimeoutMs',
101
+ label: 'command timeout',
102
+ description: 'Cancel a single command that runs longer than this and remind the model to set '
103
+ + 'its own timeout. Catches a local model that runs a command which never returns '
104
+ + '(hung build, dev server, no-timeout check) so the run stops itself instead of '
105
+ + 'waiting for a manual abort. off disables it',
106
+ // Display human labels; the stored config value stays the ms number.
107
+ values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
98
108
  }
99
109
  ];
110
+ /** Human label for the stored command-timeout ms (falls back to the raw ms). */
111
+ function timeoutLabel(ms) {
112
+ return COMMAND_TIMEOUT_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
113
+ }
100
114
  /** What /task-config shows for a setting's current value. */
101
115
  function displayValue(cfg, id, isEnum) {
102
116
  if (id === 'searchProvider')
103
117
  return SEARCH_PROVIDER_LABELS[cfg.searchProvider];
118
+ if (id === 'requestTimeoutMs')
119
+ return timeoutLabel(cfg.requestTimeoutMs);
104
120
  if (isEnum)
105
121
  return String(cfg[id]);
106
122
  return cfg[id] ? 'on' : 'off';
@@ -173,6 +189,11 @@ async function handleTaskConfig(_args, ctx) {
173
189
  if (provider)
174
190
  cfg.searchProvider = provider;
175
191
  }
192
+ else if (id === 'requestTimeoutMs') {
193
+ const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === newValue);
194
+ if (opt)
195
+ cfg.requestTimeoutMs = opt.ms;
196
+ }
176
197
  else {
177
198
  ;
178
199
  cfg[id] = newValue === 'on';
@@ -185,7 +206,7 @@ async function handleTaskConfig(_args, ctx) {
185
206
  export function registerConfig(pi) {
186
207
  registerBridgeCommand(pi, 'task-config', {
187
208
  description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation, '
188
- + 'enforce guidelines, extension whitelist for child sessions).',
209
+ + 'enforce guidelines, command timeout, extension whitelist for child sessions).',
189
210
  handler: handleTaskConfig
190
211
  });
191
212
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { registerTaskAuto } from './task/auto-orchestrator.js';
4
4
  import { registerWorkers } from './workers/index.js';
5
5
  import { registerRemote } from './remote/register.js';
6
6
  import { registerThinkingCompression } from './thinking/compress.js';
7
+ import { registerCommandWatchdog } from './task/command-watchdog.js';
7
8
  export default function (pi) {
8
9
  registerConfig(pi);
9
10
  registerTask(pi);
@@ -11,4 +12,5 @@ export default function (pi) {
11
12
  registerWorkers(pi);
12
13
  registerRemote(pi);
13
14
  registerThinkingCompression(pi);
15
+ registerCommandWatchdog(pi);
14
16
  }
@@ -0,0 +1,107 @@
1
+ export interface RuntimeRef {
2
+ /** Repo-relative normalized path (posix separators, no leading ./). */
3
+ path: string;
4
+ /** Where the reference lives: repo-relative file, `package.json scripts.X`, or 'spec'. */
5
+ referencer: string;
6
+ /** The construct that matched (Bun.file, readFile, script src, …). */
7
+ construct: string;
8
+ /** file = must exist as a file; dir = a static root that must exist as a directory. */
9
+ kind: 'file' | 'dir';
10
+ }
11
+ export interface DanglingRef extends RuntimeRef {
12
+ /** Why the producer resolution came up empty (human- and prompt-readable). */
13
+ reason: string;
14
+ }
15
+ /** Everything the tree/scripts/build POSITIVELY produce. */
16
+ export interface ProducedOutputs {
17
+ /** Exact output files (tailwind -o, --outfile, redirects, Bun.write, cp dest…). */
18
+ files: Set<string>;
19
+ /** Enumerable outdir → output STEMS (basename sans extension) it emits —
20
+ * from parsed Bun.build entrypoints, explicit output files, source args. */
21
+ enumerable: Map<string, Set<string>>;
22
+ /** Dirs produced by machinery we could not enumerate (vite/tsc/next/unknown
23
+ * commands naming them) — everything under them steps aside. */
24
+ opaque: Set<string>;
25
+ /** Dirs known to be created (mkdir, outdirs) — satisfies dir-kind refs. */
26
+ dirs: Set<string>;
27
+ }
28
+ export declare function emptyProducers(): ProducedOutputs;
29
+ /** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
30
+ * tails (HTML), trailing slash. Returns null when the literal is not a
31
+ * checkable relative path (URL, absolute, template hole, glob — step aside). */
32
+ export declare function normalizeRefPath(raw: string): string | null;
33
+ /** Extract runtime refs from one JS/TS source. */
34
+ export declare function extractJsRefs(source: string, referencer: string): RuntimeRef[];
35
+ /** Extract local-asset refs from one HTML source. */
36
+ export declare function extractHtmlRefs(source: string, referencer: string): RuntimeRef[];
37
+ /** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
38
+ * the first path-shaped source arg of a runner command. */
39
+ export declare function extractScriptEntrypoints(body: string, referencer: string): RuntimeRef[];
40
+ /**
41
+ * Producer facts from ONE shell command: output flags (`-o x`, `--outfile x`,
42
+ * `--outdir d`), redirects, `cp`/`mv`/`touch` destinations, `mkdir` dirs, known
43
+ * opaque bundlers. Leftover path tokens of an UNRECOGNIZED command escalate any
44
+ * directory they point into to opaque — that command may produce there, and
45
+ * inconclusive is never evidence. `escalate: false` disables that escalation
46
+ * for command text embedded in PROSE (a markdown bullet's surrounding words
47
+ * tokenize as junk "commands" and would opaque half the spec's paths).
48
+ */
49
+ export declare function collectProducersFromCommand(cmd: string, prod: ProducedOutputs, opts?: {
50
+ escalate?: boolean;
51
+ }): void;
52
+ /**
53
+ * Producer facts from JS/TS source: write-side calls (`Bun.write`,
54
+ * `writeFile(Sync)`, `createWriteStream`, `copyFile` dest, `mkdir(Sync)`),
55
+ * `Bun.build({entrypoints, outdir})` (enumerable — unless a `naming` option
56
+ * makes the output names underivable, then opaque), `outfile:`, and `Bun.spawn`
57
+ * argv arrays re-fed through the shell-command collector (the mx5 build.ts
58
+ * shape: tailwind's `-o dist/app.css` lives in a spawn array).
59
+ */
60
+ export declare function collectProducersFromSource(source: string, prod: ProducedOutputs): void;
61
+ /**
62
+ * Discover everything the project's own machinery produces: package.json script
63
+ * bodies, build files those scripts run (plus conventional root build files),
64
+ * tsconfig/vite outDirs.
65
+ */
66
+ export declare function discoverProducers(cwd: string): ProducedOutputs;
67
+ /**
68
+ * Resolve refs against existence + producers. DANGLING requires POSITIVE
69
+ * evidence (see the module doc): the ref sits under an ENUMERATED output dir
70
+ * and is not among its outputs, or is a missing source-only-extension file
71
+ * (nothing ever builds a `.ts`/`.tsx`). Everything inconclusive steps aside.
72
+ */
73
+ export declare function resolveDanglingRefs(refs: RuntimeRef[], prod: ProducedOutputs, exists: (rel: string) => boolean): DanglingRef[];
74
+ /**
75
+ * FINAL-GATE seam: scan the shipped tree for dangling runtime references.
76
+ * Deterministic, read-only, best-effort (throws nothing in normal operation;
77
+ * callers still guard). Producers are discovered first (scripts + build files +
78
+ * configs), then every authored source contributes refs AND runtime write-side
79
+ * producers (an app that writes its own cache file satisfies its own read).
80
+ */
81
+ export declare function findDanglingArtifacts(cwd: string): DanglingRef[];
82
+ /** Ranked-failure text for the final gate (names referencer + missing path). */
83
+ export declare function danglingGateFailureText(d: DanglingRef): string;
84
+ /** Does the spec LIST the file as its own artifact — a file-tree entry or a
85
+ * bullet whose first token is (or ends with) the basename? Prose that merely
86
+ * mentions the name mid-sentence ("serves the built index.html") does NOT
87
+ * count: that is the CONSUMING side, exactly what must not self-satisfy. */
88
+ export declare function specListsFile(spec: string, refPath: string): boolean;
89
+ /** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
90
+ export declare function extractSpecProseRefs(spec: string): RuntimeRef[];
91
+ /**
92
+ * PLAN-TIME seam: runtime refs in the spec's snippets AND consuming prose that
93
+ * neither the existing scaffold (`fileExists`), the spec's parsed build
94
+ * outputs, nor its own file tree produce. Each result becomes an UNOWNED
95
+ * coverage area until some task title claims the artifact.
96
+ */
97
+ export declare function findSpecDanglingArtifacts(spec: string, fileExists: (rel: string) => boolean): DanglingRef[];
98
+ /** Does some task title claim the artifact (by basename or full path)? Titles
99
+ * are the one plan artifact the model cannot fake ownership INTO — mentioning
100
+ * the file is the grounded signal a producing task exists. */
101
+ export declare function titlesCoverArtifact(titles: string[], ref: {
102
+ path: string;
103
+ }): boolean;
104
+ /** Coverage-loop `missing` entry for an unowned dangling artifact. */
105
+ export declare function danglingMissingText(d: DanglingRef): string;
106
+ /** Carried-requirement line when still unowned at coverage exhaustion. */
107
+ export declare function danglingCarryText(d: DanglingRef): string;