pi-workflow-engine 0.13.0 → 1.0.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.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,13 +1,14 @@
1
1
  import { Type } from "typebox";
2
2
  import { createHash } from "node:crypto";
3
- import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
3
+ import type { LoadedWorkflow, WorkflowApi, WorkflowMeta } from "./types.ts";
4
4
  import { unknownErrorMessage } from "./unknown-error.ts";
5
5
  import { parseWorkflowMeta } from "./workflow-module.ts";
6
6
 
7
7
  /**
8
8
  * v1 inline workflow source contract:
9
9
  * - The script starts with `export const meta = { ... };` where the object is a pure literal.
10
- * - The script contains exactly one `export default async ...` workflow function.
10
+ * - The script contains exactly one `export default async ...` workflow function with a block body,
11
+ * followed by nothing but an optional `;` and comments.
11
12
  * - Inline scripts must not use `import` or any exports other than `meta` and the default function.
12
13
  * - `Type` is injected lexically by the host; scripts must use that `Type`, not import typebox.
13
14
  */
@@ -19,25 +20,19 @@ export class InlineWorkflowCompileError extends Error {
19
20
  }
20
21
  }
21
22
 
22
- export interface InlineMetaLiteral {
23
- readonly literal: string;
24
- readonly value: unknown;
25
- readonly endOffset: number;
26
- }
27
-
28
23
  type InlineWorkflowExecutor = (api: WorkflowApi, typebox: typeof Type) => Promise<unknown>;
29
24
  type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecutor;
30
25
 
31
26
  const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
32
27
 
33
28
  export function compileInlineWorkflow(source: string): LoadedWorkflow {
34
- rejectForbiddenModuleSyntax(source);
29
+ const code = maskNonCode(source);
30
+ rejectImportSyntax(code);
35
31
  const metaLiteral = extractMetaLiteral(source);
36
32
  const parsedMeta = parseWorkflowMeta(metaLiteral.value);
37
33
  if ("reason" in parsedMeta) throw new InlineWorkflowCompileError(parsedMeta.reason);
38
34
 
39
- const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
40
- const executor = compileExecutor(defaultExpression);
35
+ const executor = compileExecutor(extractDefaultWorkflowExpression(source, code, metaLiteral.endOffset));
41
36
  return {
42
37
  meta: parsedMeta.meta,
43
38
  default: (api) => executor(api, Type),
@@ -45,7 +40,17 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
45
40
  };
46
41
  }
47
42
 
48
- export function extractMetaLiteral(source: string): InlineMetaLiteral {
43
+ /** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
44
+ export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
45
+ try {
46
+ const parsed = parseWorkflowMeta(extractMetaLiteral(source).value);
47
+ return "meta" in parsed ? parsed.meta : undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function extractMetaLiteral(source: string): { readonly value: unknown; readonly endOffset: number } {
49
54
  const start = skipWhitespace(source, 0);
50
55
  const prefix = /export\s+const\s+meta\s*=/y;
51
56
  prefix.lastIndex = start;
@@ -61,83 +66,84 @@ export function extractMetaLiteral(source: string): InlineMetaLiteral {
61
66
 
62
67
  const parser = new LiteralParser(source, objectStart);
63
68
  const value = parser.parseValue();
64
- const literalEnd = parser.offset;
65
- let endOffset = skipWhitespace(source, literalEnd);
69
+ let endOffset = skipWhitespace(source, parser.offset);
66
70
  if (source[endOffset] === ";") endOffset++;
67
71
 
68
- return { literal: source.slice(objectStart, literalEnd), value, endOffset };
72
+ return { value, endOffset };
69
73
  }
70
74
 
71
- function rejectForbiddenModuleSyntax(source: string): void {
72
- if (/^\s*import\s/m.test(source)) {
73
- throw new InlineWorkflowCompileError("inline workflows must not contain import statements; use injected Type instead");
74
- }
75
- if (/\bimport\s*\(/.test(source)) {
76
- throw new InlineWorkflowCompileError("inline workflows must not use dynamic import()");
77
- }
75
+ /**
76
+ * Enforce the no-`import` rule on code only, so prompts may mention imports. This is a lint
77
+ * against honest mistakes, not a security boundary: inline code can still reach eval.
78
+ */
79
+ function rejectImportSyntax(code: string): void {
80
+ // `import` as a keyword: not part of a longer identifier, a `.import` member, or an `import:` key.
81
+ const match = /(?<![\w$]|[^.]\.)import(?![\w$])(?!\s*:)(\s*\()?/.exec(code);
82
+ if (!match) return;
83
+ throw new InlineWorkflowCompileError(
84
+ match[1] === undefined
85
+ ? "inline workflows must not contain import statements; use injected Type instead"
86
+ : "inline workflows must not use dynamic import()",
87
+ );
78
88
  }
79
89
 
80
- function extractDefaultWorkflowExpression(source: string, startOffset: number): string {
81
- const moduleSource = source.slice(startOffset).trim();
82
- if (!moduleSource) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
83
- if (!moduleSource.startsWith("export default")) {
90
+ function extractDefaultWorkflowExpression(source: string, code: string, startOffset: number): string {
91
+ const start = skipWhitespace(code, startOffset);
92
+ if (start === code.length) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
93
+ const prefix = /export\s+default\b\s*/y;
94
+ prefix.lastIndex = start;
95
+ if (!prefix.test(code)) {
84
96
  throw new InlineWorkflowCompileError("inline workflow default export must directly follow the meta declaration");
85
97
  }
86
98
 
87
- const defaultPrefix = /^export\s+default\s+/;
88
- const match = defaultPrefix.exec(moduleSource);
89
- if (!match) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
90
-
91
- const expressionSource = moduleSource.slice(match[0].length).trimStart();
92
- const bodyOpen = findDefaultBodyOpen(expressionSource);
93
- const bodyClose = findMatchingDelimiter(expressionSource, bodyOpen, "{", "}");
94
- const expression = expressionSource.slice(0, bodyClose + 1);
95
- const remainder = expressionSource.slice(bodyClose + 1).trim();
96
- if (remainder !== "" && remainder !== ";") {
99
+ const expressionStart = prefix.lastIndex;
100
+ const expressionEnd = defaultFunctionEnd(code, expressionStart);
101
+ if (!/^\s*;?\s*$/.test(code.slice(expressionEnd))) {
97
102
  throw new InlineWorkflowCompileError("inline workflow must not contain code after the default export");
98
103
  }
99
- return expression;
104
+ return source.slice(expressionStart, expressionEnd);
100
105
  }
101
106
 
102
- function findDefaultBodyOpen(expressionSource: string): number {
103
- const functionMatch = /^async\s+function(?:\s+[A-Za-z_$][\w$]*)?\s*/.exec(expressionSource);
104
- if (functionMatch) {
105
- const paramsOpen = skipWhitespace(expressionSource, functionMatch[0].length);
106
- if (expressionSource[paramsOpen] !== "(") {
107
- throw new InlineWorkflowCompileError("default async function must declare parameters with parentheses");
108
- }
109
- const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
110
- const bodyOpen = skipWhitespace(expressionSource, paramsClose + 1);
111
- if (expressionSource[bodyOpen] !== "{") {
112
- throw new InlineWorkflowCompileError("default async function must use a block body");
113
- }
114
- return bodyOpen;
107
+ /**
108
+ * End of the default export in the code-only view: the brace closing the function's block body.
109
+ * The parser cannot find it, because `fn, 5` and `fn\n(api)` (an immediate call) both parse as
110
+ * one expression; cutting here rejects them before any workflow code runs. Like the import rule,
111
+ * this is a lint over the masked view, not a security boundary.
112
+ */
113
+ function defaultFunctionEnd(code: string, start: number): number {
114
+ const head = /async\s+(?<keyword>function\b)\s*(?:[A-Za-z_$][\w$]*\s*)?(?=\()|async\s*(?=\()|async\s+[A-Za-z_$][\w$]*\s*(?==>)/y;
115
+ head.lastIndex = start;
116
+ const match = head.exec(code);
117
+ if (!match) {
118
+ throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
115
119
  }
116
120
 
117
- const arrowMatch = /^async\s*/.exec(expressionSource);
118
- if (arrowMatch) {
119
- const paramsOpen = skipWhitespace(expressionSource, arrowMatch[0].length);
120
- if (expressionSource[paramsOpen] !== "(") {
121
- throw new InlineWorkflowCompileError("default async arrow workflow must declare parameters with parentheses");
122
- }
123
- const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
124
- let cursor = skipWhitespace(expressionSource, paramsClose + 1);
125
- if (!expressionSource.startsWith("=>", cursor)) {
126
- throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
127
- }
128
- cursor = skipWhitespace(expressionSource, cursor + 2);
129
- if (expressionSource[cursor] !== "{") {
130
- throw new InlineWorkflowCompileError("default async arrow workflow must use a block body");
131
- }
132
- return cursor;
121
+ let cursor = head.lastIndex;
122
+ if (code[cursor] === "(") cursor = skipWhitespace(code, closingBracket(code, cursor) + 1);
123
+ if (match.groups?.keyword === undefined) {
124
+ if (!code.startsWith("=>", cursor)) throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
125
+ cursor = skipWhitespace(code, cursor + 2);
133
126
  }
127
+ if (code[cursor] !== "{") throw new InlineWorkflowCompileError("default async workflow must use a block body");
128
+ return closingBracket(code, cursor) + 1;
129
+ }
134
130
 
135
- throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
131
+ /** Index of the bracket closing the one at `open`, counting every bracket kind in the code-only view. */
132
+ function closingBracket(code: string, open: number): number {
133
+ let depth = 0;
134
+ for (let index = open; index < code.length; index++) {
135
+ const char = code[index];
136
+ if (char === "(" || char === "[" || char === "{") depth++;
137
+ else if ((char === ")" || char === "]" || char === "}") && --depth === 0) return index;
138
+ }
139
+ throw new InlineWorkflowCompileError("inline workflow default export has an unclosed bracket");
136
140
  }
137
141
 
138
142
  function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
139
143
  try {
140
- return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
144
+ // The expression already ends at the function body's closing brace; the parentheses also make
145
+ // the parser reject anything that is not a single expression.
146
+ return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = (\n${defaultExpression}\n);\nreturn await workflow(api);`);
141
147
  } catch (error) {
142
148
  throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
143
149
  }
@@ -330,56 +336,115 @@ class LiteralParser {
330
336
  }
331
337
  }
332
338
 
333
- function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
334
- let depth = 1;
335
- let index = openIndex + 1;
339
+ const REGEX_PREFIX_KEYWORDS = new Set([
340
+ "await", "case", "delete", "do", "else", "in", "instanceof", "new", "of", "return", "throw", "typeof", "void", "yield",
341
+ ]);
342
+ const CONTROL_HEAD_KEYWORDS = new Set(["for", "if", "while"]);
343
+
344
+ /**
345
+ * Blank the contents of strings, template text, comments and regex literals, keeping offsets
346
+ * and delimiters, so keyword and bracket checks see only code. A `/` after `]`, `}` or any `)`
347
+ * but an `if`/`for`/`while` head's is read as division, so a regex literal opening a statement
348
+ * after a block's `}` stays unmasked. The parser still decides whether a script is valid.
349
+ */
350
+ function maskNonCode(source: string): string {
351
+ const masked = source.split("");
352
+ const blank = (start: number, end: number): number => {
353
+ masked.fill(" ", start, end);
354
+ return end;
355
+ };
356
+ // One entry per open brace; true when it is a template `${` whose `}` resumes template text.
357
+ const braces: boolean[] = [];
358
+ const templateText = (start: number): number => {
359
+ for (let index = start; index < source.length; index++) {
360
+ if (source[index] === "\\") index++;
361
+ else if (source[index] === "`") return blank(start, index) + 1;
362
+ else if (source.startsWith("${", index)) {
363
+ braces.push(true);
364
+ return blank(start, index) + 2;
365
+ }
366
+ }
367
+ return blank(start, source.length);
368
+ };
369
+ // One entry per open paren; true when it opens an `if`/`for`/`while` head, after which `/` starts a regex.
370
+ const parens: boolean[] = [];
371
+ const controlHeadEnds = new Set<number>();
372
+
373
+ let index = 0;
336
374
  while (index < source.length) {
337
375
  const char = source[index];
338
- if (char === '"' || char === "'" || char === "`") {
339
- index = skipQuotedSource(source, index, char);
340
- continue;
376
+ if (source.startsWith("//", index)) {
377
+ const newline = source.indexOf("\n", index);
378
+ index = blank(index, newline === -1 ? source.length : newline);
379
+ } else if (source.startsWith("/*", index)) {
380
+ const close = source.indexOf("*/", index + 2);
381
+ index = blank(index, close === -1 ? source.length : close + 2);
382
+ } else if (char === '"' || char === "'") {
383
+ index = blank(index + 1, quotedTextEnd(source, index + 1, char)) + 1;
384
+ } else if (char === "`") {
385
+ index = templateText(index + 1);
386
+ } else if (char === "{") {
387
+ braces.push(false);
388
+ index++;
389
+ } else if (char === "}") {
390
+ index = braces.pop() === true ? templateText(index + 1) : index + 1;
391
+ } else if (char === "(") {
392
+ parens.push(CONTROL_HEAD_KEYWORDS.has(wordBefore(masked, index)));
393
+ index++;
394
+ } else if (char === ")") {
395
+ if (parens.pop() === true) controlHeadEnds.add(index);
396
+ index++;
397
+ } else if (char === "/" && regexCanStart(masked, index, controlHeadEnds)) {
398
+ const close = regexLiteralEnd(source, index + 1);
399
+ index = close === undefined ? index + 1 : blank(index + 1, close) + 1;
400
+ } else {
401
+ index++;
341
402
  }
342
- if (char === "/" && source[index + 1] === "/") {
343
- index = skipLineComment(source, index);
344
- continue;
345
- }
346
- if (char === "/" && source[index + 1] === "*") {
347
- index = skipBlockComment(source, index);
348
- continue;
349
- }
350
- if (char === open) depth++;
351
- if (char === close) {
352
- depth--;
353
- if (depth === 0) return index;
354
- }
355
- index++;
356
403
  }
357
- throw new InlineWorkflowCompileError(`unterminated ${open}${close} block in inline workflow default export`);
404
+ return masked.join("");
358
405
  }
359
406
 
360
- function skipQuotedSource(source: string, start: number, quote: string): number {
361
- let index = start + 1;
362
- while (index < source.length) {
407
+ /** Index of the closing quote, or of the line break that leaves a broken string confined to one line. */
408
+ function quotedTextEnd(source: string, start: number, quote: string): number {
409
+ for (let index = start; index < source.length; index++) {
363
410
  const char = source[index];
364
- if (char === "\\") {
365
- index += 2;
366
- continue;
367
- }
368
- if (char === quote) return index + 1;
369
- index++;
411
+ if (char === "\\") index++;
412
+ else if (char === quote || char === "\n") return index;
370
413
  }
371
- throw new InlineWorkflowCompileError("unterminated string in inline workflow default export");
414
+ return source.length;
372
415
  }
373
416
 
374
- function skipLineComment(source: string, start: number): number {
375
- const newline = source.indexOf("\n", start + 2);
376
- return newline === -1 ? source.length : newline + 1;
417
+ /** Whether a `/` here starts a regex literal, judged by the code token before it. */
418
+ function regexCanStart(masked: readonly string[], slash: number, controlHeadEnds: ReadonlySet<number>): boolean {
419
+ let end = slash - 1;
420
+ while (end >= 0 && /\s/.test(masked[end])) end--;
421
+ if (end < 0) return true;
422
+ if (masked[end] === ")") return controlHeadEnds.has(end);
423
+ if (/[\w$]/.test(masked[end])) return REGEX_PREFIX_KEYWORDS.has(wordBefore(masked, slash));
424
+ return !/[\]}"'`]/.test(masked[end]);
377
425
  }
378
426
 
379
- function skipBlockComment(source: string, start: number): number {
380
- const end = source.indexOf("*/", start + 2);
381
- if (end === -1) throw new InlineWorkflowCompileError("unterminated block comment in inline workflow default export");
382
- return end + 2;
427
+ /** The identifier or keyword ending just before `index`, skipping whitespace; empty when there is none. */
428
+ function wordBefore(masked: readonly string[], index: number): string {
429
+ let end = index;
430
+ while (end > 0 && /\s/.test(masked[end - 1])) end--;
431
+ let start = end;
432
+ while (start > 0 && /[\w$]/.test(masked[start - 1])) start--;
433
+ return masked.slice(start, end).join("");
434
+ }
435
+
436
+ /** Index of the closing `/` of a regex literal body starting at `start`, if it closes on this line. */
437
+ function regexLiteralEnd(source: string, start: number): number | undefined {
438
+ let inClass = false;
439
+ for (let index = start; index < source.length; index++) {
440
+ const char = source[index];
441
+ if (char === "\\") index++;
442
+ else if (char === "\n") return undefined;
443
+ else if (char === "[") inClass = true;
444
+ else if (char === "]") inClass = false;
445
+ else if (char === "/" && !inClass) return index;
446
+ }
447
+ return undefined;
383
448
  }
384
449
 
385
450
  function skipWhitespace(source: string, start: number): number {
@@ -64,18 +64,6 @@ export function workflowJournalPath(cwd: string, runId: string): string {
64
64
  return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
65
65
  }
66
66
 
67
- export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
68
- const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
69
- return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
70
- }
71
-
72
- export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
73
- const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
74
- return capture.kind === "verified"
75
- ? capture.hash
76
- : createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
77
- }
78
-
79
67
  /** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
80
68
  export function captureAgentJournalKey(
81
69
  prompt: string,
@@ -1,3 +1,5 @@
1
+ import { homedir } from "node:os";
2
+ import { relative, sep } from "node:path";
1
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
4
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
5
  import {
@@ -78,7 +80,7 @@ export function registerWorkflowModelProfileCommand(pi: ExtensionAPI): void {
78
80
  const prefix = command.kind === "status"
79
81
  ? "Workflow model profiles"
80
82
  : `${command.profile} ${command.kind === "set" ? "updated" : "cleared"} in ${command.scope} config`;
81
- ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths)}`, "info");
83
+ ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths, ctx.cwd)}`, "info");
82
84
  } catch (error) {
83
85
  ctx.ui.notify(unknownErrorMessage(error), "error");
84
86
  }
@@ -152,16 +154,43 @@ export function parseWorkflowModelsCommand(args: string): WorkflowModelsCommand
152
154
  };
153
155
  }
154
156
 
155
- function formatWorkflowModelProfiles(
157
+ /**
158
+ * An aligned plain-text table (notifications cannot style or measure the terminal):
159
+ * one row per profile, then the two config files with home and project paths shortened.
160
+ */
161
+ export function formatWorkflowModelProfiles(
156
162
  profiles: ResolvedWorkflowModelProfiles,
157
163
  paths: WorkflowModelProfilePaths,
164
+ cwd: string,
165
+ home: string = homedir(),
158
166
  ): string {
159
- const lines = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
167
+ const rows = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
160
168
  const profile = profiles[name];
161
- const model = profile.model ? `${profile.model.provider}/${profile.model.id}` : "(no host model)";
162
- const thinking = profile.thinkingLevel ?? "host";
163
- const source = profile.source === "host" ? "host fallback" : `${profile.source}: ${profile.configPath}`;
164
- return `${name}: ${model} · thinking ${thinking} · ${source}`;
169
+ return [
170
+ name,
171
+ profile.model ? `${profile.model.provider}/${profile.model.id}` : "no host model",
172
+ profile.thinkingLevel ?? "host",
173
+ profile.source === "host" ? "host fallback" : `${profile.source} config`,
174
+ ];
165
175
  });
166
- return [...lines, `user: ${paths.user}`, `project: ${paths.project} (overrides user)`].join("\n");
176
+ const configs = [
177
+ ["user", displayPath(paths.user, cwd, home)],
178
+ ["project", `${displayPath(paths.project, cwd, home)} (overrides user)`],
179
+ ];
180
+ return [...alignColumns([["profile", "model", "thinking", "source"], ...rows]), "", ...alignColumns(configs)].join("\n");
181
+ }
182
+
183
+ function alignColumns(rows: readonly (readonly string[])[]): string[] {
184
+ const widths = rows.reduce<number[]>(
185
+ (max, row) => row.map((cell, index) => Math.max(max[index] ?? 0, cell.length)),
186
+ [],
187
+ );
188
+ return rows.map((row) => ` ${row.map((cell, index) => (index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0))).join(" ")}`);
189
+ }
190
+
191
+ /** Project paths relative to the cwd, others with the home directory as `~`. */
192
+ function displayPath(path: string, cwd: string, home: string): string {
193
+ if (path.startsWith(cwd + sep)) return relative(cwd, path);
194
+ if (home && path.startsWith(home + sep)) return `~${path.slice(home.length)}`;
195
+ return path;
167
196
  }
@@ -5,6 +5,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
6
  import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
7
7
  import { isMissingPathError } from "./filesystem-error.ts";
8
+ import { isRecord } from "./guards.ts";
8
9
  import { unknownErrorMessage } from "./unknown-error.ts";
9
10
 
10
11
  export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
@@ -43,19 +44,6 @@ export interface WorkflowModelRouteRequest {
43
44
  readonly profile?: WorkflowModelProfileName;
44
45
  }
45
46
 
46
- export class WorkflowModelProfileConfigError extends Error {
47
- override readonly name = "WorkflowModelProfileConfigError";
48
- readonly code = "WORKFLOW_MODEL_PROFILE_CONFIG";
49
-
50
- constructor(
51
- message: string,
52
- readonly configPath: string,
53
- readonly profile?: WorkflowModelProfileName,
54
- ) {
55
- super(message);
56
- }
57
- }
58
-
59
47
  export const WORKFLOW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
60
48
  const THINKING_LEVELS = new Set<ThinkingLevel>(WORKFLOW_THINKING_LEVELS);
61
49
  const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> = {
@@ -140,19 +128,15 @@ export function readWorkflowModelProfileFile(configPath: string): WorkflowModelP
140
128
  contents = readFileSync(configPath, "utf8");
141
129
  } catch (error) {
142
130
  if (isMissingPathError(error)) return { profiles: {} };
143
- throw new WorkflowModelProfileConfigError(
131
+ throw new Error(
144
132
  `Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
145
- configPath,
146
133
  );
147
134
  }
148
135
  let parsed: unknown;
149
136
  try {
150
137
  parsed = JSON.parse(contents);
151
138
  } catch (error) {
152
- throw new WorkflowModelProfileConfigError(
153
- `Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`,
154
- configPath,
155
- );
139
+ throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
156
140
  }
157
141
  return parseWorkflowModelProfileFile(parsed, configPath);
158
142
  }
@@ -198,10 +182,10 @@ function parseWorkflowModelProfileFile(value: unknown, configPath: string): Work
198
182
  if (!isWorkflowModelProfileName(name)) {
199
183
  throw invalidConfig(configPath, `unknown profile "${name}"; expected small, medium, or big`);
200
184
  }
201
- if (!isRecord(profile)) throw invalidConfig(configPath, `profile "${name}" must be an object`, name);
202
- assertOnlyKeys(profile, ["model", "thinkingLevel"], configPath, name);
185
+ if (!isRecord(profile)) throw invalidConfig(configPath, `profile "${name}" must be an object`);
186
+ assertOnlyKeys(profile, ["model", "thinkingLevel"], configPath);
203
187
  if (typeof profile.model !== "string") {
204
- throw invalidConfig(configPath, `profile "${name}" requires a provider-qualified "model" string`, name);
188
+ throw invalidConfig(configPath, `profile "${name}" requires a provider-qualified "model" string`);
205
189
  }
206
190
  assertThinkingLevel(profile.thinkingLevel, configPath, name);
207
191
  profiles[name] = {
@@ -218,29 +202,18 @@ function resolveConfiguredModel(
218
202
  modelRegistry: Pick<ModelRegistry, "find">,
219
203
  configPath: string,
220
204
  ): Model<Api> {
205
+ // Exactly "provider/model": no whitespace, a non-empty provider, and a model id that neither starts nor ends with "/".
221
206
  const slash = modelRef.indexOf("/");
222
- if (modelRef.trim() !== modelRef || slash <= 0 || slash === modelRef.length - 1) {
223
- throw invalidConfig(
224
- configPath,
225
- `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
226
- name,
227
- );
207
+ if (/\s/.test(modelRef) || slash <= 0 || modelRef.endsWith("/") || modelRef[slash + 1] === "/") {
208
+ throw invalidConfig(configPath, `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`);
228
209
  }
229
210
  const provider = modelRef.slice(0, slash);
230
211
  const modelId = modelRef.slice(slash + 1);
231
- if (/\s/.test(provider) || modelId.startsWith("/") || modelId.endsWith("/") || /\s/.test(modelId)) {
232
- throw invalidConfig(
233
- configPath,
234
- `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
235
- name,
236
- );
237
- }
238
212
  const model = modelRegistry.find(provider, modelId);
239
213
  if (!model) {
240
214
  throw invalidConfig(
241
215
  configPath,
242
216
  `profile "${name}" references unavailable model "${modelRef}"; choose an exact model shown by /model and update it with /workflow:models set ${name} provider/model`,
243
- name,
244
217
  );
245
218
  }
246
219
  return model;
@@ -257,21 +230,12 @@ function assertThinkingLevel(
257
230
  profile: WorkflowModelProfileName,
258
231
  ): asserts value is ThinkingLevel | undefined {
259
232
  if (value === undefined || (typeof value === "string" && isWorkflowThinkingLevel(value))) return;
260
- throw invalidConfig(
261
- configPath,
262
- `profile "${profile}" thinkingLevel must be off, minimal, low, medium, high, xhigh, or max`,
263
- profile,
264
- );
233
+ throw invalidConfig(configPath, `profile "${profile}" thinkingLevel must be off, minimal, low, medium, high, xhigh, or max`);
265
234
  }
266
235
 
267
- function assertOnlyKeys(
268
- value: Record<string, unknown>,
269
- allowed: readonly string[],
270
- configPath: string,
271
- profile?: WorkflowModelProfileName,
272
- ): void {
236
+ function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string[], configPath: string): void {
273
237
  const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
274
- if (unexpected) throw invalidConfig(configPath, `unexpected key "${unexpected}"`, profile);
238
+ if (unexpected) throw invalidConfig(configPath, `unexpected key "${unexpected}"`);
275
239
  }
276
240
 
277
241
  function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
@@ -286,25 +250,10 @@ function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModel
286
250
  } catch {
287
251
  // Best effort: preserve the original write error.
288
252
  }
289
- throw new WorkflowModelProfileConfigError(
290
- `Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`,
291
- configPath,
292
- );
253
+ throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
293
254
  }
294
255
  }
295
256
 
296
- function invalidConfig(
297
- configPath: string,
298
- reason: string,
299
- profile?: WorkflowModelProfileName,
300
- ): WorkflowModelProfileConfigError {
301
- return new WorkflowModelProfileConfigError(
302
- `Invalid workflow model profile config at ${configPath}: ${reason}.`,
303
- configPath,
304
- profile,
305
- );
306
- }
307
-
308
- function isRecord(value: unknown): value is Record<string, unknown> {
309
- return typeof value === "object" && value !== null && !Array.isArray(value);
257
+ function invalidConfig(configPath: string, reason: string): Error {
258
+ return new Error(`Invalid workflow model profile config at ${configPath}: ${reason}.`);
310
259
  }