pi-long-task 0.6.0 → 0.7.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.
@@ -15,13 +15,28 @@ export interface GuardedSessionPromptOptions {
15
15
  gracefulShutdownPrompt?: string;
16
16
  diagnostics?: string[];
17
17
  onEvent?: (event: unknown) => void;
18
+ /** Bounded elapsed-time checkpoints while the primary deadline is active. */
19
+ progressCheckpointsMs?: readonly number[];
20
+ onProgressCheckpoint?: (elapsedMs: number) => void;
21
+ /** Called exactly when a positive grace period begins. */
22
+ onGracePeriodStart?: (gracePeriodMs: number) => void;
18
23
  dispose?: boolean;
19
24
  }
20
25
 
21
26
  export interface GuardedSessionPromptResult {
22
27
  assistantText: string;
28
+ /** True when the primary prompt deadline elapsed, even if the prompt safely completed during grace. */
23
29
  timedOut: boolean;
30
+ /** True only when a timed-out prompt settled during the configured grace period. */
31
+ completedDuringGrace: boolean;
32
+ /** True when non-whitespace assistant output was observed before prompt termination. */
33
+ outputObserved: boolean;
34
+ /** True when the session had to be stopped because its grace period expired. */
35
+ graceExpired: boolean;
36
+ /** True when the session was stopped for any reason, including hard timeout. */
24
37
  aborted: boolean;
38
+ /** True only when the caller's AbortSignal cancelled the prompt. */
39
+ cancelled: boolean;
25
40
  error?: string;
26
41
  /** Untouched prompt failure for coordinator-level provider/transport classification. */
27
42
  failure?: unknown;
@@ -39,14 +54,19 @@ export async function runGuardedSessionPrompt(
39
54
  const events: unknown[] = [];
40
55
  const timers = new Set<ReturnType<typeof setTimeout>>();
41
56
  let assistantText = "";
57
+ let currentAssistantText = "";
58
+ let outputObserved = false;
42
59
  let timedOut = false;
60
+ let graceExpired = false;
43
61
  let aborted = false;
62
+ let cancelled = false;
44
63
  let error: string | undefined;
45
64
  let failure: unknown;
46
65
  let promptSettled = false;
47
66
  let finished = false;
48
67
  let unsubscribe: (() => void) | undefined;
49
68
  let complete: (() => void) | undefined;
69
+ const assistantTextAtStart = latestAssistantText(session, [], "");
50
70
 
51
71
  const completed = new Promise<void>((resolve) => {
52
72
  complete = resolve;
@@ -90,6 +110,14 @@ export async function runGuardedSessionPrompt(
90
110
  }
91
111
  };
92
112
 
113
+ const notifyTiming = (callback: (() => void) | undefined, label: string) => {
114
+ try {
115
+ callback?.();
116
+ } catch (exc) {
117
+ diagnostics.push(`${label} listener failed: ${errorMessage(exc)}`);
118
+ }
119
+ };
120
+
93
121
  const requestGracefulShutdown = () => {
94
122
  const message = options.gracefulShutdownPrompt?.trim();
95
123
  if (!message || finished || promptSettled || aborted) {
@@ -130,13 +158,18 @@ export async function runGuardedSessionPrompt(
130
158
  }
131
159
  timedOut = true;
132
160
  diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
133
- requestGracefulShutdown();
134
161
 
135
162
  const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
163
+ if (graceMs > 0) {
164
+ notifyTiming(() => options.onGracePeriodStart?.(graceMs), "grace-period progress");
165
+ }
166
+ requestGracefulShutdown();
167
+
136
168
  const hardAbort = () => {
137
169
  if (finished || promptSettled) {
138
170
  return;
139
171
  }
172
+ graceExpired = true;
140
173
  abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
141
174
  resolveCompleted();
142
175
  };
@@ -152,20 +185,34 @@ export async function runGuardedSessionPrompt(
152
185
  if (finished || promptSettled) {
153
186
  return;
154
187
  }
155
- abortSession(abortReason(options.abortSignal, "session prompt aborted by outer signal"));
188
+ cancelled = true;
189
+ abortSession(abortReason(options.abortSignal, "session prompt cancelled by outer signal"));
156
190
  resolveCompleted();
157
191
  };
158
192
 
159
193
  try {
160
194
  if (options.abortSignal?.aborted) {
161
195
  aborted = true;
162
- error = abortReason(options.abortSignal, "session prompt aborted before start");
196
+ cancelled = true;
197
+ error = abortReason(options.abortSignal, "session prompt cancelled before start");
163
198
  } else {
164
199
  unsubscribe = session.subscribe((event: unknown) => {
165
200
  events.push(event);
166
- const text = assistantTextFromEvent(event);
167
- if (text) {
168
- assistantText = text;
201
+ if (isAssistantMessageStart(event)) {
202
+ currentAssistantText = "";
203
+ }
204
+ const delta = assistantTextDeltaFromEvent(event);
205
+ if (delta !== undefined) {
206
+ currentAssistantText += delta;
207
+ assistantText = currentAssistantText || assistantText;
208
+ outputObserved ||= delta.trim().length > 0;
209
+ } else {
210
+ const text = assistantTextFromEvent(event);
211
+ if (text) {
212
+ currentAssistantText = text;
213
+ assistantText = text;
214
+ outputObserved ||= text.trim().length > 0;
215
+ }
169
216
  }
170
217
  try {
171
218
  options.onEvent?.(event);
@@ -192,6 +239,13 @@ export async function runGuardedSessionPrompt(
192
239
 
193
240
  const limitMs = timeoutMs(options.timeoutMs);
194
241
  if (limitMs > 0) {
242
+ for (const checkpoint of normalizedProgressCheckpoints(options.progressCheckpointsMs, limitMs)) {
243
+ schedule(() => {
244
+ if (!finished && !promptSettled && !timedOut && !aborted) {
245
+ notifyTiming(() => options.onProgressCheckpoint?.(checkpoint), "timing progress");
246
+ }
247
+ }, checkpoint);
248
+ }
195
249
  schedule(triggerTimeout, limitMs);
196
250
  }
197
251
 
@@ -206,6 +260,7 @@ export async function runGuardedSessionPrompt(
206
260
  options.abortSignal?.removeEventListener("abort", abortListener);
207
261
  unsubscribe?.();
208
262
  assistantText = latestAssistantText(session, events, assistantText);
263
+ outputObserved ||= assistantText.trim().length > 0 && assistantText !== assistantTextAtStart;
209
264
  if (options.dispose !== false) {
210
265
  try {
211
266
  const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
@@ -220,7 +275,20 @@ export async function runGuardedSessionPrompt(
220
275
  }
221
276
  }
222
277
 
223
- return buildResult(session, events, assistantText, timedOut, aborted, error, failure, diagnostics);
278
+ return buildResult(
279
+ session,
280
+ events,
281
+ assistantText,
282
+ timedOut,
283
+ timedOut && promptSettled && !graceExpired && !aborted && failure === undefined,
284
+ outputObserved,
285
+ graceExpired,
286
+ aborted,
287
+ cancelled,
288
+ error,
289
+ failure,
290
+ diagnostics,
291
+ );
224
292
  }
225
293
 
226
294
  function buildResult(
@@ -228,7 +296,11 @@ function buildResult(
228
296
  events: unknown[],
229
297
  assistantText: string,
230
298
  timedOut: boolean,
299
+ completedDuringGrace: boolean,
300
+ outputObserved: boolean,
301
+ graceExpired: boolean,
231
302
  aborted: boolean,
303
+ cancelled: boolean,
232
304
  error: string | undefined,
233
305
  failure: unknown,
234
306
  diagnostics: string[],
@@ -236,7 +308,11 @@ function buildResult(
236
308
  return {
237
309
  assistantText: latestAssistantText(session, events, assistantText),
238
310
  timedOut,
311
+ completedDuringGrace,
312
+ outputObserved,
313
+ graceExpired,
239
314
  aborted,
315
+ cancelled,
240
316
  error,
241
317
  ...(failure === undefined ? {} : { failure }),
242
318
  diagnostics: [...diagnostics],
@@ -266,6 +342,16 @@ function timeoutMs(value: number | undefined): number {
266
342
  return Math.max(0, value);
267
343
  }
268
344
 
345
+ function normalizedProgressCheckpoints(values: readonly number[] | undefined, limitMs: number): number[] {
346
+ if (!values) {
347
+ return [];
348
+ }
349
+ return [...new Set(values)]
350
+ .filter((value) => Number.isFinite(value) && value > 0 && value < limitMs)
351
+ .map((value) => Math.floor(value))
352
+ .sort((left, right) => left - right);
353
+ }
354
+
269
355
  function nonNegativeMilliseconds(value: number | undefined): number {
270
356
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
271
357
  return 0;
@@ -277,6 +363,26 @@ function formatMilliseconds(ms: number): string {
277
363
  return `${(ms / 1000).toFixed(3)}s`;
278
364
  }
279
365
 
366
+ function isAssistantMessageStart(event: unknown): boolean {
367
+ return (
368
+ isRecord(event) && event.type === "message_start" && isRecord(event.message) && event.message.role === "assistant"
369
+ );
370
+ }
371
+
372
+ function assistantTextDeltaFromEvent(event: unknown): string | undefined {
373
+ if (!isRecord(event) || event.type !== "message_update" || !isRecord(event.assistantMessageEvent)) {
374
+ return undefined;
375
+ }
376
+ const assistantEvent = event.assistantMessageEvent;
377
+ return assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string"
378
+ ? assistantEvent.delta
379
+ : undefined;
380
+ }
381
+
382
+ function isRecord(value: unknown): value is Record<string, unknown> {
383
+ return typeof value === "object" && value !== null;
384
+ }
385
+
280
386
  function abortReason(signal: AbortSignal | undefined, fallback: string): string {
281
387
  const reason = signal?.reason;
282
388
  if (reason === undefined) {
@@ -133,7 +133,7 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
133
133
  return markdown;
134
134
  }
135
135
 
136
- let next = insertGlobalGoalInstructions(markdown, trimmedGoal);
136
+ let next = insertGlobalInstructions(markdown, goalInstructionLines(trimmedGoal));
137
137
  const coverageGoal = parseCoverageGoal(trimmedGoal);
138
138
  if (coverageGoal) {
139
139
  next = appendCoverageVerificationToTasks(next, coverageGoalVerifyBullet(coverageGoal));
@@ -142,14 +142,33 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
142
142
  return next;
143
143
  }
144
144
 
145
- function insertGlobalGoalInstructions(markdown: string, goal: string): string {
145
+ export function applyWorkerCapabilityConstraintsToTodoMarkdown(
146
+ markdown: string,
147
+ constraints: readonly string[],
148
+ ): string {
149
+ const additions = constraints
150
+ .map(oneLine)
151
+ .filter(Boolean)
152
+ .map((item) => `- ${item}`);
153
+ if (additions.length === 0) {
154
+ return markdown;
155
+ }
156
+ const next = insertGlobalInstructions(markdown, additions);
157
+ validateTodoMarkdown(next);
158
+ return next;
159
+ }
160
+
161
+ function goalInstructionLines(goal: string): string[] {
146
162
  const coverageGoal = parseCoverageGoal(goal);
147
163
  const additions = [`- Long task goal: ${goal}`];
148
164
  if (coverageGoal) {
149
165
  additions.push(`- Coverage goal: ${coverageGoalAction(coverageGoal)}`);
150
166
  additions.push(`- Coverage verification: ${coverageGoalVerification(coverageGoal)}`);
151
167
  }
168
+ return additions;
169
+ }
152
170
 
171
+ function insertGlobalInstructions(markdown: string, additions: readonly string[]): string {
153
172
  const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
154
173
  const progressIndex = lines.findIndex((line) => /^##\s+Progress\s*$/i.test(line.trim()));
155
174
  if (progressIndex < 0) {
@@ -229,9 +248,43 @@ function oneLine(value: string): string {
229
248
  return value.replace(/\s+/g, " ").trim();
230
249
  }
231
250
 
232
- export function buildTodoCreationPrompt(rawInput: string, goal?: string): string {
251
+ export function todoPlanningOnlyPromptBlock(capabilityConstraints: readonly string[] = []): string {
252
+ const capabilityBlock = capabilityConstraints.length
253
+ ? `\n\nWorker capability constraints (preserve these above ## Progress and in affected tasks):\n${capabilityConstraints
254
+ .map((constraint) => `- ${oneLine(constraint)}`)
255
+ .join("\n")}`
256
+ : "";
257
+ return `Planning-only boundary:
258
+ - Produce only a concise executable plan for future workers.
259
+ - Do not perform requested end work: do not implement or write code, execute research or report findings, create requested creative output (prose, stories, copy, designs, or assets), or produce any other final deliverable.
260
+ - Use future-worker action language; do not claim work is complete or invent results.
261
+ - Keep repeated task sections compact: use a one-sentence Goal and Done when, plus only the necessary Status and Verify bullets. Omit rationale, lengthy analysis, summaries, duplicated context, unrequested examples, and boilerplate.
262
+ - Preserve every instruction, constraint, required deliverable, and acceptance condition from the source request and supplied planning context. Put shared constraints above ## Progress and task-specific requirements in the relevant task.${capabilityBlock}`;
263
+ }
264
+
265
+ export function buildTodoCreationPrompt(
266
+ rawInput: string,
267
+ goal?: string,
268
+ capabilityConstraints: readonly string[] = [],
269
+ ): string {
233
270
  const goalBlock = todoGoalPromptBlock(goal);
234
- return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.\n\nRequirements:\n- Output only markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n${goalBlock}\nRaw input:\n\n${rawInput.trim()}\n`;
271
+ return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.
272
+
273
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
274
+
275
+ Required format:
276
+ - Output only markdown, with no commentary and no code fence.
277
+ - Start with exactly: # Pi Long Task TODO
278
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
279
+ - Include a --- separator before task sections.
280
+ - Create sequential sections named ## TODO N — Title.
281
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
282
+ - Keep tasks focused and independently assignable to worker sessions.
283
+ ${goalBlock}
284
+ Raw input:
285
+
286
+ ${rawInput.trim()}
287
+ `;
235
288
  }
236
289
 
237
290
  export function buildTodoRepairPrompt(
@@ -239,9 +292,33 @@ export function buildTodoRepairPrompt(
239
292
  invalidOutput: string,
240
293
  validationError: string,
241
294
  goal?: string,
295
+ capabilityConstraints: readonly string[] = [],
242
296
  ): string {
243
297
  const goalBlock = todoGoalPromptBlock(goal);
244
- return `Your previous response was not valid Pi Long Task TODO markdown. Correct it now.\n\nValidation/extraction error:\n${validationError.trim() || "Unknown validation error."}\n\nRequirements:\n- Output only corrected markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n${goalBlock}\nOriginal raw input:\n\n${rawInput.trim()}\n\nPrevious invalid output:\n\n${invalidOutput.trim()}\n`;
298
+ return `Repair the previous response into valid Pi Long Task TODO markdown. Correct its plan and format only; do not continue or perform any attempted end work.
299
+
300
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
301
+
302
+ Validation/extraction error:
303
+ ${validationError.trim() || "Unknown validation error."}
304
+
305
+ Required format:
306
+ - Output only corrected markdown, with no commentary and no code fence.
307
+ - Start with exactly: # Pi Long Task TODO
308
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
309
+ - Include a --- separator before task sections.
310
+ - Create sequential sections named ## TODO N — Title.
311
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
312
+ - Keep tasks focused and independently assignable to worker sessions.
313
+ ${goalBlock}
314
+ Original raw input:
315
+
316
+ ${rawInput.trim()}
317
+
318
+ Previous invalid output (repair its planning content; do not extend its end work):
319
+
320
+ ${invalidOutput.trim()}
321
+ `;
245
322
  }
246
323
 
247
324
  function todoGoalPromptBlock(goal: string | undefined): string {
package/src/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Static } from "typebox";
2
2
  import { Type } from "typebox";
3
3
 
4
+ import { MAX_PLANNER_DURATION_MS, type PlannerBudget } from "./planner_config.ts";
4
5
  import type { TaskProgressModel } from "./task_progress.ts";
6
+ import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
5
7
  import type { SessionOutcome } from "./worker_session.ts";
6
8
 
7
9
  const NetworkRecoveryParams = Type.Object(
@@ -51,6 +53,22 @@ export const PiLongTaskParams = Type.Object(
51
53
  description: "Optional high-level goal or desired outcome for the long-task run.",
52
54
  }),
53
55
  ),
56
+ todoTimeoutMs: Type.Optional(
57
+ Type.Integer({
58
+ minimum: 1,
59
+ maximum: MAX_PLANNER_DURATION_MS,
60
+ description:
61
+ "Explicit TODO-planner timeout in milliseconds. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
62
+ }),
63
+ ),
64
+ todoGracefulShutdownMs: Type.Optional(
65
+ Type.Integer({
66
+ minimum: 0,
67
+ maximum: MAX_PLANNER_DURATION_MS,
68
+ description:
69
+ "Grace period in milliseconds after the TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
70
+ }),
71
+ ),
54
72
  networkRecovery: Type.Optional(NetworkRecoveryParams),
55
73
  },
56
74
  { additionalProperties: false },
@@ -113,6 +131,22 @@ export const PiGoalTaskParams = Type.Object(
113
131
  description: "Maximum bash command timeout in milliseconds allowed in worker sessions.",
114
132
  }),
115
133
  ),
134
+ todoTimeoutMs: Type.Optional(
135
+ Type.Integer({
136
+ minimum: 1,
137
+ maximum: MAX_PLANNER_DURATION_MS,
138
+ description:
139
+ "Explicit TODO-planner timeout in milliseconds for child long-task planning and plan revisions. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
140
+ }),
141
+ ),
142
+ todoGracefulShutdownMs: Type.Optional(
143
+ Type.Integer({
144
+ minimum: 0,
145
+ maximum: MAX_PLANNER_DURATION_MS,
146
+ description:
147
+ "Grace period in milliseconds after a child TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
148
+ }),
149
+ ),
116
150
  networkRecovery: Type.Optional(NetworkRecoveryParams),
117
151
  },
118
152
  { additionalProperties: false },
@@ -165,6 +199,8 @@ export interface PiLongTaskResult {
165
199
  }>;
166
200
  taskProgress: TaskProgressModel;
167
201
  workerCostTotal: number;
202
+ plannerBudget?: Readonly<PlannerBudget>;
203
+ capabilityWarnings?: readonly WorkerCapabilityWarning[];
168
204
  commit: boolean;
169
205
  goal?: string;
170
206
  error?: string;
@@ -0,0 +1,103 @@
1
+ export interface IsolatedWorkerCapabilities {
2
+ /** Tool names exposed directly to the worker session. */
3
+ tools: readonly string[];
4
+ /** Pi Long Task isolated sessions deliberately disable extension runtimes. */
5
+ extensionsEnabled: boolean;
6
+ }
7
+
8
+ export type WorkerCapabilityWarningCode = "unavailable_browser_capability";
9
+
10
+ export interface WorkerCapabilityWarning {
11
+ code: WorkerCapabilityWarningCode;
12
+ requestedCapabilities: readonly string[];
13
+ availableTools: readonly string[];
14
+ message: string;
15
+ planningConstraint: string;
16
+ }
17
+
18
+ const BROWSER_EXTENSION_REQUIREMENT_RE =
19
+ /\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|rely(?:ing)?\s+on|use|using|via|through|with)\s+(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome|chromium|firefox|edge|browser)(?:\s+(?:browser|devtools?))?\s+(?:extension|mcp|connector)\b/i;
20
+ const REQUIRED_NAMED_BROWSER_EXTENSION_RE =
21
+ /\b(?:chrome\s+devtools\s+(?:mcp|extension|tool)|(?:chrome|browser)\s+(?:mcp|extension(?:\s+tool)?|tool\s+extension))\s+(?:is\s+(?:required|needed)|must\s+be\s+used)\b/i;
22
+ const IMPERATIVE_CHROME_RE =
23
+ /\b(?:open|launch|control|drive|browse\s+with|inspect\s+(?:with|using)|scrape\s+(?:with|using)|fetch\s+(?:with|using)|test\s+(?:with|using))\s+(?:google\s+)?chrome\b/i;
24
+ const EXPLICIT_BROWSER_TOOL_RE =
25
+ /\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|use|using|via|through|with)\s+(?:the\s+)?(browser|chrome|web[_-]?fetch|playwright|puppeteer)\s+tool\b/i;
26
+ const EXPLICIT_CHROME_RUNTIME_RE =
27
+ /(?:\b(?:must\s+use|need(?:s|ed)?\s+to\s+use|use|using)\s+(?:google\s+)?chrome(?:\s+devtools?)?(?=\s+(?:to|for)\b|\s*[.,;:]|\s*$)|\b(?:via|through)\s+(?:google\s+)?chrome\b|\b(?:google\s+)?chrome(?:\s+devtools?)?\s+(?:is\s+required|must\s+be\s+used)\b)/i;
28
+ const NEGATED_CAPABILITY_RE =
29
+ /\b(?:do\s+not|don't|dont|never|avoid|without)\s+(?:use|using|rely(?:ing)?\s+on|requiring?)?\s*(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome(?:\s+(?:browser|devtools?))?|chromium|firefox|edge|browser|web[_-]?fetch|playwright|puppeteer)(?:\s+(?:extension|mcp|tool(?:ing)?|connector))?/gi;
30
+
31
+ /**
32
+ * Detect only explicit requests to invoke a browser capability. Merely asking
33
+ * workers to build or support a browser extension is implementation work and
34
+ * does not imply that the extension must be loaded during the run.
35
+ */
36
+ export function detectUnavailableWorkerCapabilities(
37
+ requestText: string,
38
+ capabilities: Readonly<IsolatedWorkerCapabilities>,
39
+ ): WorkerCapabilityWarning[] {
40
+ const text = requestText.replace(NEGATED_CAPABILITY_RE, " ");
41
+ const extensionRequested =
42
+ BROWSER_EXTENSION_REQUIREMENT_RE.test(text) ||
43
+ REQUIRED_NAMED_BROWSER_EXTENSION_RE.test(text) ||
44
+ IMPERATIVE_CHROME_RE.test(text);
45
+ const browserToolMatch = EXPLICIT_BROWSER_TOOL_RE.exec(text);
46
+ const requestedDirectTools = [
47
+ browserToolMatch?.[1],
48
+ EXPLICIT_CHROME_RUNTIME_RE.test(text) ? "chrome" : undefined,
49
+ ].filter((item, index, all): item is string => Boolean(item) && all.indexOf(item) === index);
50
+ const unavailableDirectTools = requestedDirectTools.filter(
51
+ (requested) =>
52
+ !capabilities.tools.some((available) => canonicalToolName(available) === canonicalToolName(requested)),
53
+ );
54
+
55
+ if ((!extensionRequested || capabilities.extensionsEnabled) && unavailableDirectTools.length === 0) {
56
+ return [];
57
+ }
58
+
59
+ const requestedCapabilities = [
60
+ extensionRequested && !capabilities.extensionsEnabled ? "Chrome/browser extension runtime" : undefined,
61
+ unavailableDirectTools.length > 0 ? `${unavailableDirectTools.join("/")} direct tool` : undefined,
62
+ ].filter((item): item is string => Boolean(item));
63
+ const availableTools = [...capabilities.tools];
64
+ const toolList = availableTools.length > 0 ? availableTools.join(", ") : "none";
65
+ const alternatives = availableWorkerAlternatives(capabilities);
66
+ const alternativeText = alternatives.join(", or ");
67
+
68
+ return [
69
+ {
70
+ code: "unavailable_browser_capability",
71
+ requestedCapabilities,
72
+ availableTools,
73
+ message:
74
+ `Worker capability warning: isolated Pi Long Task workers disable extensions and expose only these direct tools: ${toolList}. ` +
75
+ `They cannot silently use the requested ${requestedCapabilities.join(" or ")}. The run will continue, but it must use an available safe alternative when that satisfies the request: ${alternativeText}. ` +
76
+ "If the exact extension or browser tool is mandatory, the affected task must report blocked instead of claiming it used that capability.",
77
+ planningConstraint:
78
+ `Isolated-worker capability constraint: extensions are disabled and workers have only these direct tools: ${toolList}. ` +
79
+ `Do not create or execute tasks that assume the requested ${requestedCapabilities.join(" or ")} is available, and never claim it was used. ` +
80
+ `When equivalent, ${alternativeText}. If the exact unavailable capability is mandatory, make the affected task report blocked with the required user action.`,
81
+ },
82
+ ];
83
+ }
84
+
85
+ function canonicalToolName(tool: string): string {
86
+ return tool.toLowerCase().replace(/[-_]/g, "");
87
+ }
88
+
89
+ function availableWorkerAlternatives(capabilities: Readonly<IsolatedWorkerCapabilities>): string[] {
90
+ const alternatives: string[] = [];
91
+ if (capabilities.tools.includes("bash")) {
92
+ alternatives.push(
93
+ "fetch public content through a supported command-line mechanism via bash or run project-provided browser automation via bash when available",
94
+ );
95
+ }
96
+ if (capabilities.tools.includes("read")) {
97
+ alternatives.push("supply the page or source content to the run so workers can read it locally");
98
+ }
99
+ if (alternatives.length === 0) {
100
+ alternatives.push("supply the needed source content to the run");
101
+ }
102
+ return alternatives;
103
+ }
@@ -3,11 +3,14 @@ import {
3
3
  resolveNetworkRecoveryConfig,
4
4
  type NetworkRecoveryConfigInput,
5
5
  } from "./network_recovery_config.ts";
6
+ import { MAX_PLANNER_DURATION_MS, PlannerDurationConfigError } from "./planner_config.ts";
6
7
 
7
8
  export interface ParsedWorkerRuntimeConfig {
8
9
  modelName?: string;
9
10
  maxAttemptsPerTask?: number;
10
11
  taskTimeoutMs?: number;
12
+ todoTimeoutMs?: number;
13
+ todoGracefulShutdownMs?: number;
11
14
  maxBashTimeoutMs?: number;
12
15
  workerSessionReuseEnabled?: boolean;
13
16
  workerSessionReuseContextThresholdPercent?: number;
@@ -52,6 +55,8 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
52
55
  ...(modelName ? { modelName } : {}),
53
56
  ...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
54
57
  ...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
58
+ ...(state.todoTimeoutMs !== undefined ? { todoTimeoutMs: state.todoTimeoutMs } : {}),
59
+ ...(state.todoGracefulShutdownMs !== undefined ? { todoGracefulShutdownMs: state.todoGracefulShutdownMs } : {}),
55
60
  ...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
56
61
  ...(state.workerSessionReuseEnabled !== undefined
57
62
  ? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
@@ -135,14 +140,36 @@ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntim
135
140
 
136
141
  captureDurations(
137
142
  text,
138
- /\b(?<!bash\s)(?<!max\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
143
+ /\b(?:todo\s+)?(?:planner|planning)\s+timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
144
+ (value) => {
145
+ state.todoTimeoutMs = value;
146
+ },
147
+ );
148
+ captureDurations(
149
+ text,
150
+ /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:todo\s+)?(?:planner|planning)\s+timeout\b/gi,
151
+ (value) => {
152
+ state.todoTimeoutMs = value;
153
+ },
154
+ );
155
+ captureDurations(
156
+ text,
157
+ /\b(?:todo\s+)?(?:planner|planning)\s+(?:grace(?:ful)?(?:\s+shutdown|\s+period)?|shutdown\s+grace(?:\s+period)?)\s*(?:duration\s*)?(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
158
+ (value) => {
159
+ state.todoGracefulShutdownMs = value;
160
+ },
161
+ { allowZero: true },
162
+ );
163
+ captureDurations(
164
+ text,
165
+ /\b(?<!bash\s)(?<!max\s)(?<!planner\s)(?<!planning\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
139
166
  (value) => {
140
167
  state.taskTimeoutMs = value;
141
168
  },
142
169
  );
143
170
  captureDurations(
144
171
  text,
145
- /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:worker\s+|task\s+)?timeout\b/gi,
172
+ /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))[ \t]+(?:worker[ \t]+|task[ \t]+)?timeout\b/gi,
146
173
  (value) => {
147
174
  state.taskTimeoutMs = value;
148
175
  },
@@ -215,6 +242,16 @@ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeC
215
242
  return;
216
243
  }
217
244
 
245
+ if (/\b(?:planner|planning)\b/.test(key) && /\b(?:grace|graceful|shutdown)\b/.test(key)) {
246
+ state.todoGracefulShutdownMs = requiredPlannerDuration("graceful-shutdown duration", value, true);
247
+ return;
248
+ }
249
+
250
+ if (/\b(?:planner|planning)\b/.test(key) && /\btimeout\b/.test(key)) {
251
+ state.todoTimeoutMs = requiredPlannerDuration("timeout", value, false);
252
+ return;
253
+ }
254
+
218
255
  if (/\bbash\b/.test(key) && /\btimeout\b/.test(key)) {
219
256
  const timeout = durationMsFromText(value, { allowBareSeconds: true });
220
257
  if (timeout !== undefined) {
@@ -268,6 +305,23 @@ function applyNetworkRecoveryDirective(key: string, value: string, state: Mutabl
268
305
  throw new NetworkRecoveryConfigError(`Unknown network recovery configuration directive: ${key}.`);
269
306
  }
270
307
 
308
+ function requiredPlannerDuration(label: string, value: string, allowZero: boolean): number {
309
+ const trimmed = trimDirectiveValue(value)
310
+ .replace(/[.!]+$/g, "")
311
+ .trim();
312
+ const match = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?$/i.exec(
313
+ trimmed,
314
+ );
315
+ const milliseconds = match ? durationMsFromText(trimmed, { allowBareSeconds: true, allowZero }) : undefined;
316
+ if (milliseconds === undefined || milliseconds > MAX_PLANNER_DURATION_MS) {
317
+ const minimum = allowZero ? "non-negative" : "positive";
318
+ throw new PlannerDurationConfigError(
319
+ `TODO planner ${label} must be a ${minimum} finite duration no greater than about 24.9 days (${MAX_PLANNER_DURATION_MS} milliseconds), for example 30s or 5m.`,
320
+ );
321
+ }
322
+ return milliseconds;
323
+ }
324
+
271
325
  function requiredNetworkRecoveryDuration(label: string, value: string): number {
272
326
  const trimmed = trimDirectiveValue(value)
273
327
  .replace(/[.!]+$/g, "")
@@ -307,9 +361,17 @@ function captureNumbers(text: string, pattern: RegExp, apply: (value: number) =>
307
361
  }
308
362
  }
309
363
 
310
- function captureDurations(text: string, pattern: RegExp, apply: (value: number) => void): void {
364
+ function captureDurations(
365
+ text: string,
366
+ pattern: RegExp,
367
+ apply: (value: number) => void,
368
+ options: { allowZero?: boolean } = {},
369
+ ): void {
311
370
  for (const match of text.matchAll(pattern)) {
312
- const value = durationMsFromText(match[1] ?? "", { allowBareSeconds: true });
371
+ const value = durationMsFromText(match[1] ?? "", {
372
+ allowBareSeconds: true,
373
+ allowZero: options.allowZero,
374
+ });
313
375
  if (value !== undefined) {
314
376
  apply(value);
315
377
  }
@@ -373,7 +435,10 @@ function booleanSetting(value: string): boolean | undefined {
373
435
  return undefined;
374
436
  }
375
437
 
376
- function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
438
+ function durationMsFromText(
439
+ value: string,
440
+ options: { allowBareSeconds: boolean; allowZero?: boolean },
441
+ ): number | undefined {
377
442
  const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
378
443
  value,
379
444
  );
@@ -382,7 +447,7 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
382
447
  }
383
448
 
384
449
  const amount = Number.parseFloat(match[1] ?? "");
385
- if (!Number.isFinite(amount) || amount <= 0) {
450
+ if (!Number.isFinite(amount) || amount < 0 || (!options.allowZero && amount === 0)) {
386
451
  return undefined;
387
452
  }
388
453
 
@@ -393,7 +458,9 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
393
458
 
394
459
  const multiplier = durationMultiplier(unit || "seconds");
395
460
  const milliseconds = Math.round(amount * multiplier);
396
- return Number.isSafeInteger(milliseconds) && milliseconds > 0 ? milliseconds : undefined;
461
+ return Number.isSafeInteger(milliseconds) && (options.allowZero ? milliseconds >= 0 : milliseconds > 0)
462
+ ? milliseconds
463
+ : undefined;
397
464
  }
398
465
 
399
466
  function durationMultiplier(unit: string): number {