pi-long-task 0.6.0 → 0.7.1

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,30 @@ 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
 
26
+ const MAX_CAPTURED_SESSION_EVENTS = 512;
27
+
21
28
  export interface GuardedSessionPromptResult {
22
29
  assistantText: string;
30
+ /** True when the primary prompt deadline elapsed, even if the prompt safely completed during grace. */
23
31
  timedOut: boolean;
32
+ /** True only when a timed-out prompt settled during the configured grace period. */
33
+ completedDuringGrace: boolean;
34
+ /** True when non-whitespace assistant output was observed before prompt termination. */
35
+ outputObserved: boolean;
36
+ /** True when the session had to be stopped because its grace period expired. */
37
+ graceExpired: boolean;
38
+ /** True when the session was stopped for any reason, including hard timeout. */
24
39
  aborted: boolean;
40
+ /** True only when the caller's AbortSignal cancelled the prompt. */
41
+ cancelled: boolean;
25
42
  error?: string;
26
43
  /** Untouched prompt failure for coordinator-level provider/transport classification. */
27
44
  failure?: unknown;
@@ -39,14 +56,19 @@ export async function runGuardedSessionPrompt(
39
56
  const events: unknown[] = [];
40
57
  const timers = new Set<ReturnType<typeof setTimeout>>();
41
58
  let assistantText = "";
59
+ let currentAssistantTextChunks: string[] = [];
60
+ let outputObserved = false;
42
61
  let timedOut = false;
62
+ let graceExpired = false;
43
63
  let aborted = false;
64
+ let cancelled = false;
44
65
  let error: string | undefined;
45
66
  let failure: unknown;
46
67
  let promptSettled = false;
47
68
  let finished = false;
48
69
  let unsubscribe: (() => void) | undefined;
49
70
  let complete: (() => void) | undefined;
71
+ const assistantTextAtStart = latestAssistantText(session, [], "");
50
72
 
51
73
  const completed = new Promise<void>((resolve) => {
52
74
  complete = resolve;
@@ -90,6 +112,14 @@ export async function runGuardedSessionPrompt(
90
112
  }
91
113
  };
92
114
 
115
+ const notifyTiming = (callback: (() => void) | undefined, label: string) => {
116
+ try {
117
+ callback?.();
118
+ } catch (exc) {
119
+ diagnostics.push(`${label} listener failed: ${errorMessage(exc)}`);
120
+ }
121
+ };
122
+
93
123
  const requestGracefulShutdown = () => {
94
124
  const message = options.gracefulShutdownPrompt?.trim();
95
125
  if (!message || finished || promptSettled || aborted) {
@@ -130,13 +160,18 @@ export async function runGuardedSessionPrompt(
130
160
  }
131
161
  timedOut = true;
132
162
  diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
133
- requestGracefulShutdown();
134
163
 
135
164
  const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
165
+ if (graceMs > 0) {
166
+ notifyTiming(() => options.onGracePeriodStart?.(graceMs), "grace-period progress");
167
+ }
168
+ requestGracefulShutdown();
169
+
136
170
  const hardAbort = () => {
137
171
  if (finished || promptSettled) {
138
172
  return;
139
173
  }
174
+ graceExpired = true;
140
175
  abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
141
176
  resolveCompleted();
142
177
  };
@@ -152,20 +187,35 @@ export async function runGuardedSessionPrompt(
152
187
  if (finished || promptSettled) {
153
188
  return;
154
189
  }
155
- abortSession(abortReason(options.abortSignal, "session prompt aborted by outer signal"));
190
+ cancelled = true;
191
+ abortSession(abortReason(options.abortSignal, "session prompt cancelled by outer signal"));
156
192
  resolveCompleted();
157
193
  };
158
194
 
159
195
  try {
160
196
  if (options.abortSignal?.aborted) {
161
197
  aborted = true;
162
- error = abortReason(options.abortSignal, "session prompt aborted before start");
198
+ cancelled = true;
199
+ error = abortReason(options.abortSignal, "session prompt cancelled before start");
163
200
  } else {
164
201
  unsubscribe = session.subscribe((event: unknown) => {
165
202
  events.push(event);
166
- const text = assistantTextFromEvent(event);
167
- if (text) {
168
- assistantText = text;
203
+ if (events.length > MAX_CAPTURED_SESSION_EVENTS) events.shift();
204
+ if (isAssistantMessageStart(event)) {
205
+ currentAssistantTextChunks = [];
206
+ assistantText = "";
207
+ }
208
+ const delta = assistantTextDeltaFromEvent(event);
209
+ if (delta !== undefined) {
210
+ if (delta) currentAssistantTextChunks.push(delta);
211
+ outputObserved ||= delta.trim().length > 0;
212
+ } else {
213
+ const text = assistantTextFromEvent(event);
214
+ if (text) {
215
+ currentAssistantTextChunks = [text];
216
+ assistantText = text;
217
+ outputObserved ||= text.trim().length > 0;
218
+ }
169
219
  }
170
220
  try {
171
221
  options.onEvent?.(event);
@@ -192,6 +242,13 @@ export async function runGuardedSessionPrompt(
192
242
 
193
243
  const limitMs = timeoutMs(options.timeoutMs);
194
244
  if (limitMs > 0) {
245
+ for (const checkpoint of normalizedProgressCheckpoints(options.progressCheckpointsMs, limitMs)) {
246
+ schedule(() => {
247
+ if (!finished && !promptSettled && !timedOut && !aborted) {
248
+ notifyTiming(() => options.onProgressCheckpoint?.(checkpoint), "timing progress");
249
+ }
250
+ }, checkpoint);
251
+ }
195
252
  schedule(triggerTimeout, limitMs);
196
253
  }
197
254
 
@@ -205,7 +262,8 @@ export async function runGuardedSessionPrompt(
205
262
  clearTimers();
206
263
  options.abortSignal?.removeEventListener("abort", abortListener);
207
264
  unsubscribe?.();
208
- assistantText = latestAssistantText(session, events, assistantText);
265
+ assistantText = latestAssistantText(session, events, currentAssistantTextChunks.join("") || assistantText);
266
+ outputObserved ||= assistantText.trim().length > 0 && assistantText !== assistantTextAtStart;
209
267
  if (options.dispose !== false) {
210
268
  try {
211
269
  const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
@@ -220,7 +278,20 @@ export async function runGuardedSessionPrompt(
220
278
  }
221
279
  }
222
280
 
223
- return buildResult(session, events, assistantText, timedOut, aborted, error, failure, diagnostics);
281
+ return buildResult(
282
+ session,
283
+ events,
284
+ assistantText,
285
+ timedOut,
286
+ timedOut && promptSettled && !graceExpired && !aborted && failure === undefined,
287
+ outputObserved,
288
+ graceExpired,
289
+ aborted,
290
+ cancelled,
291
+ error,
292
+ failure,
293
+ diagnostics,
294
+ );
224
295
  }
225
296
 
226
297
  function buildResult(
@@ -228,7 +299,11 @@ function buildResult(
228
299
  events: unknown[],
229
300
  assistantText: string,
230
301
  timedOut: boolean,
302
+ completedDuringGrace: boolean,
303
+ outputObserved: boolean,
304
+ graceExpired: boolean,
231
305
  aborted: boolean,
306
+ cancelled: boolean,
232
307
  error: string | undefined,
233
308
  failure: unknown,
234
309
  diagnostics: string[],
@@ -236,7 +311,11 @@ function buildResult(
236
311
  return {
237
312
  assistantText: latestAssistantText(session, events, assistantText),
238
313
  timedOut,
314
+ completedDuringGrace,
315
+ outputObserved,
316
+ graceExpired,
239
317
  aborted,
318
+ cancelled,
240
319
  error,
241
320
  ...(failure === undefined ? {} : { failure }),
242
321
  diagnostics: [...diagnostics],
@@ -255,8 +334,10 @@ function latestAssistantText(session: WorkerSessionLike, events: unknown[], fall
255
334
  if (fromMessages) {
256
335
  return fromMessages;
257
336
  }
258
- const fromEvents = lastAssistantTextFromEvents(events);
259
- return fromEvents || fallback;
337
+ if (fallback) {
338
+ return fallback;
339
+ }
340
+ return lastAssistantTextFromEvents(events);
260
341
  }
261
342
 
262
343
  function timeoutMs(value: number | undefined): number {
@@ -266,6 +347,16 @@ function timeoutMs(value: number | undefined): number {
266
347
  return Math.max(0, value);
267
348
  }
268
349
 
350
+ function normalizedProgressCheckpoints(values: readonly number[] | undefined, limitMs: number): number[] {
351
+ if (!values) {
352
+ return [];
353
+ }
354
+ return [...new Set(values)]
355
+ .filter((value) => Number.isFinite(value) && value > 0 && value < limitMs)
356
+ .map((value) => Math.floor(value))
357
+ .sort((left, right) => left - right);
358
+ }
359
+
269
360
  function nonNegativeMilliseconds(value: number | undefined): number {
270
361
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
271
362
  return 0;
@@ -277,6 +368,26 @@ function formatMilliseconds(ms: number): string {
277
368
  return `${(ms / 1000).toFixed(3)}s`;
278
369
  }
279
370
 
371
+ function isAssistantMessageStart(event: unknown): boolean {
372
+ return (
373
+ isRecord(event) && event.type === "message_start" && isRecord(event.message) && event.message.role === "assistant"
374
+ );
375
+ }
376
+
377
+ function assistantTextDeltaFromEvent(event: unknown): string | undefined {
378
+ if (!isRecord(event) || event.type !== "message_update" || !isRecord(event.assistantMessageEvent)) {
379
+ return undefined;
380
+ }
381
+ const assistantEvent = event.assistantMessageEvent;
382
+ return assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string"
383
+ ? assistantEvent.delta
384
+ : undefined;
385
+ }
386
+
387
+ function isRecord(value: unknown): value is Record<string, unknown> {
388
+ return typeof value === "object" && value !== null;
389
+ }
390
+
280
391
  function abortReason(signal: AbortSignal | undefined, fallback: string): string {
281
392
  const reason = signal?.reason;
282
393
  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,44 @@ 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
+ - Minimize worker handoffs because each TODO starts another model assignment. Use the fewest tasks that safely preserve dependencies and explicit boundaries; combine tightly coupled implementation, tests, and documentation that use the same context. Do not create separate setup, audit, or final-verification tasks when that work belongs inside an implementation task.
263
+ - 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}`;
264
+ }
265
+
266
+ export function buildTodoCreationPrompt(
267
+ rawInput: string,
268
+ goal?: string,
269
+ capabilityConstraints: readonly string[] = [],
270
+ ): string {
233
271
  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`;
272
+ return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.
273
+
274
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
275
+
276
+ Required format:
277
+ - Output only markdown, with no commentary and no code fence.
278
+ - Start with exactly: # Pi Long Task TODO
279
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
280
+ - Include a --- separator before task sections.
281
+ - Create sequential sections named ## TODO N — Title.
282
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
283
+ - Keep tasks focused and independently assignable to worker sessions.
284
+ ${goalBlock}
285
+ Raw input:
286
+
287
+ ${rawInput.trim()}
288
+ `;
235
289
  }
236
290
 
237
291
  export function buildTodoRepairPrompt(
@@ -239,9 +293,33 @@ export function buildTodoRepairPrompt(
239
293
  invalidOutput: string,
240
294
  validationError: string,
241
295
  goal?: string,
296
+ capabilityConstraints: readonly string[] = [],
242
297
  ): string {
243
298
  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`;
299
+ 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.
300
+
301
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
302
+
303
+ Validation/extraction error:
304
+ ${validationError.trim() || "Unknown validation error."}
305
+
306
+ Required format:
307
+ - Output only corrected markdown, with no commentary and no code fence.
308
+ - Start with exactly: # Pi Long Task TODO
309
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
310
+ - Include a --- separator before task sections.
311
+ - Create sequential sections named ## TODO N — Title.
312
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
313
+ - Keep tasks focused and independently assignable to worker sessions.
314
+ ${goalBlock}
315
+ Original raw input:
316
+
317
+ ${rawInput.trim()}
318
+
319
+ Previous invalid output (repair its planning content; do not extend its end work):
320
+
321
+ ${invalidOutput.trim()}
322
+ `;
245
323
  }
246
324
 
247
325
  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
+ }