@marcoscale98/pi-extensible-workflows 5.17.0-fork.1 → 6.2.1-fork.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +442 -162
  5. package/dist/src/background-widget.d.ts +5 -1
  6. package/dist/src/background-widget.js +5 -10
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +59 -37
  9. package/dist/src/execution.d.ts +6 -2
  10. package/dist/src/execution.js +48 -7
  11. package/dist/src/host-delivery.js +5 -8
  12. package/dist/src/host-navigator.js +5 -2
  13. package/dist/src/host-phases.d.ts +1 -0
  14. package/dist/src/host-recovery.d.ts +11 -8
  15. package/dist/src/host-recovery.js +14 -22
  16. package/dist/src/host-runtime.js +56 -5
  17. package/dist/src/host-view.d.ts +6 -1
  18. package/dist/src/host-view.js +46 -20
  19. package/dist/src/host.d.ts +2 -2
  20. package/dist/src/host.js +246 -118
  21. package/dist/src/index.d.ts +1 -2
  22. package/dist/src/index.js +1985 -1690
  23. package/dist/src/index.js.map +3 -3
  24. package/dist/src/paths.d.ts +8 -5
  25. package/dist/src/paths.js +24 -13
  26. package/dist/src/persistence.d.ts +1 -0
  27. package/dist/src/persistence.js +1 -0
  28. package/dist/src/pi-runtime-runner.js +4 -1
  29. package/dist/src/registry.d.ts +9 -7
  30. package/dist/src/registry.js +29 -71
  31. package/dist/src/runtime/agent-runner.d.ts +2 -0
  32. package/dist/src/script-tools.d.ts +27 -0
  33. package/dist/src/script-tools.js +87 -0
  34. package/dist/src/session-lease.d.ts +1 -1
  35. package/dist/src/session-lease.js +45 -11
  36. package/dist/src/settings.d.ts +19 -3
  37. package/dist/src/settings.js +63 -50
  38. package/dist/src/store.d.ts +3 -1
  39. package/dist/src/store.js +17 -1
  40. package/dist/src/trajectory.d.ts +12 -3
  41. package/dist/src/trajectory.js +32 -11
  42. package/dist/src/types.d.ts +129 -45
  43. package/dist/src/types.js +5 -8
  44. package/dist/src/utils.d.ts +21 -1
  45. package/dist/src/utils.js +46 -6
  46. package/dist/src/validation.d.ts +14 -3
  47. package/dist/src/validation.js +143 -87
  48. package/dist/starter/index.js +8 -11
  49. package/dist/starter/index.js.map +1 -1
  50. package/dist/starter/review-loop.d.ts +0 -13
  51. package/dist/starter/review-loop.js +8 -9
  52. package/dist/subagents/index.js +904 -956
  53. package/dist/subagents/index.js.map +3 -3
  54. package/dist/subagents/src/contracts.d.ts +48 -31
  55. package/dist/subagents/src/contracts.js +20 -9
  56. package/dist/subagents/src/index.d.ts +1 -1
  57. package/dist/subagents/src/index.js +14 -8
  58. package/dist/subagents/src/manager.js +52 -73
  59. package/dist/subagents/src/navigator.js +3 -14
  60. package/dist/subagents/src/view.d.ts +1 -1
  61. package/dist/subagents/src/view.js +15 -68
  62. package/dist/subagents/src/worktree.js +1 -1
  63. package/dist/trajectory/assets/index.html +35 -13
  64. package/dist/trajectory/index.js +576 -932
  65. package/dist/trajectory/index.js.map +3 -3
  66. package/dist/trajectory/src/assets/index.html +35 -13
  67. package/dist/trajectory/src/index.js +134 -104
  68. package/dist/trajectory/src/server.js +268 -27
  69. package/dist/trajectory/src/server.js.map +3 -3
  70. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  71. package/dist/trajectory/src/startup-mutex.js +172 -0
  72. package/examples/workflow-extension-template/README.md +4 -7
  73. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  74. package/examples/workflow-extension-template/index.js +0 -3
  75. package/package.json +10 -10
  76. package/skills/pi-extensible-workflows/SKILL.md +12 -8
  77. package/src/agent-execution.ts +412 -164
  78. package/src/background-widget.ts +5 -11
  79. package/src/decoders.ts +51 -37
  80. package/src/execution.ts +46 -9
  81. package/src/host-delivery.ts +5 -8
  82. package/src/host-navigator.ts +4 -2
  83. package/src/host-phases.ts +1 -1
  84. package/src/host-recovery.ts +23 -28
  85. package/src/host-runtime.ts +44 -6
  86. package/src/host-view.ts +41 -16
  87. package/src/host.ts +229 -142
  88. package/src/index.ts +1 -2
  89. package/src/paths.ts +18 -9
  90. package/src/persistence.ts +1 -0
  91. package/src/pi-runtime-runner.ts +3 -1
  92. package/src/registry.ts +28 -64
  93. package/src/runtime/agent-runner.ts +2 -0
  94. package/src/script-tools.ts +76 -0
  95. package/src/session-lease.ts +34 -6
  96. package/src/settings.ts +56 -50
  97. package/src/store.ts +18 -2
  98. package/src/trajectory.ts +32 -15
  99. package/src/types.ts +82 -34
  100. package/src/utils.ts +42 -5
  101. package/src/validation.ts +121 -80
  102. package/starter/index.ts +0 -1
  103. package/starter/review-loop.ts +8 -9
  104. package/starter/roles/developer.md +0 -1
  105. package/starter/roles/oracle.md +0 -1
  106. package/starter/roles/researcher.md +0 -1
  107. package/starter/roles/reviewer.md +0 -1
  108. package/starter/roles/scout.md +0 -1
  109. package/subagents/README.md +10 -16
  110. package/subagents/src/contracts.ts +21 -11
  111. package/subagents/src/index.ts +17 -8
  112. package/subagents/src/manager.ts +48 -128
  113. package/subagents/src/navigator.ts +4 -13
  114. package/subagents/src/view.ts +16 -63
  115. package/subagents/src/worktree.ts +1 -1
  116. package/trajectory/src/assets/index.html +35 -13
  117. package/trajectory/src/index.ts +112 -91
  118. package/trajectory/src/server.ts +46 -10
  119. package/trajectory/src/startup-mutex.ts +127 -0
  120. package/dist/src/roles.d.ts +0 -70
  121. package/dist/src/roles.js +0 -325
  122. package/dist/starter/roles/developer.md +0 -6
  123. package/dist/starter/roles/oracle.md +0 -18
  124. package/dist/starter/roles/researcher.md +0 -20
  125. package/dist/starter/roles/reviewer.md +0 -19
  126. package/dist/starter/roles/scout.md +0 -18
  127. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  128. package/src/roles.ts +0 -326
package/src/index.ts CHANGED
@@ -3,10 +3,9 @@ export * from "./package-identity.js";
3
3
  export { createToolTimingExtension, TOOL_TIMING_ENTRY_TYPE } from "./tool-timing.js";
4
4
  export type { ToolTimingEntry } from "./tool-timing.js";
5
5
  export * from "./utils.js";
6
- export { canonicalPath, sameFilesystemPath } from "./paths.js";
6
+ export { canonicalPath, extensionIdentity, sameFilesystemPath } from "./paths.js";
7
7
  export * from "./budget.js";
8
8
  export * from "./validation.js";
9
- export * from "./roles.js";
10
9
  export * from "./registry.js";
11
10
  export * from "./execution.js";
12
11
  export * from "./host.js";
package/src/paths.ts CHANGED
@@ -1,24 +1,33 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { readdir } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
- import { basename, join, resolve } from "node:path";
6
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
6
7
  import { WorkflowError } from "./types.js";
7
8
  import { isNodeError } from "./utils.js";
8
9
 
9
10
  export function safePart(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/g, "_"); }
10
11
 
12
+ export function canonicalPath(path: string): string { const absolute = resolve(path); try { return realpathSync(absolute); } catch { return absolute; } }
13
+ export function extensionIdentity(path: string): string { return path.startsWith("builtin:") ? path : canonicalPath(path.startsWith("file:") ? fileURLToPath(path) : path); }
14
+ export function sameFilesystemPath(left: string, right: string): boolean { return canonicalPath(left) === canonicalPath(right); }
11
15
  /**
12
- * Resolves a path to its filesystem spelling, including symlink and platform aliases.
13
- * A missing path safely falls back to its absolute lexical spelling rather than
14
- * guessing at the identity of an object that does not exist.
16
+ * The path Pi's resource loader uses for a resource path an extension returns: trimmed, `~`, `~/` and `file://` expanded,
17
+ * relative to the loader cwd. Synthetic `<...>` and `builtin:` paths stay as they are.
15
18
  */
16
- export function canonicalPath(path: string): string {
17
- const absolute = resolve(path);
18
- try { return realpathSync(absolute); } catch { return absolute; }
19
+ export function piResourcePath(path: string, cwd: string): string {
20
+ //NOTE: Pi also rewrites Windows shell paths and `~\` on win32; those forms are not mirrored here and fail closed.
21
+ if (path.startsWith("<") || path.startsWith("builtin:")) return path;
22
+ const trimmed = path.trim();
23
+ const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : /^file:\/\//.test(trimmed) ? fileURLToPath(trimmed) : trimmed;
24
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
25
+ }
26
+ /** Pi attributes a loaded resource to a contributed path by lexical containment of resolved paths, without following symlinks. */
27
+ export function piResourceContains(root: string, path: string): boolean {
28
+ const relativePath = relative(resolve(root), resolve(path));
29
+ return relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
19
30
  }
20
-
21
- export function sameFilesystemPath(left: string, right: string): boolean { return canonicalPath(left) === canonicalPath(right); }
22
31
  export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", "pi-extensible-workflows", "settings.json"); }
23
32
 
24
33
  export function projectStorageKey(cwd: string): string {
@@ -4,6 +4,7 @@ export {
4
4
  } from "./paths.js";
5
5
  export { hasLiveSessionLease, SessionLease, acquireSessionLease, listRunIds, processAlive } from "./session-lease.js";
6
6
  export { RunStore } from "./store.js";
7
+ export { decodePreparedAgentConfiguration } from "./decoders.js";
7
8
  export {
8
9
  EffectiveSystemPrompt, PersistedRun, RunSummaryAgent, RunSummaryArtifacts, RunSummary,
9
10
  CompletedOperation, AwaitingCheckpoint, PendingWorkflowDecision, PersistedOwnershipNode,
@@ -172,7 +172,7 @@ export class PiRuntimeAgentRunner implements RuntimeAgentRunner {
172
172
  let structuredResult: RuntimeJsonValue | undefined;
173
173
  const resultTool = prepared.resultTool;
174
174
  if (!resultTool) throw new WorkflowError("INTERNAL_ERROR", "Workflow agent session is missing its workflow_result tool");
175
- const preparedForSession = preparedForRuntimeRequest(prepared, request.customTools, request.signal, (value) => { structuredResult = value; }, resultTool);
175
+ const preparedForSession = Object.freeze({ ...preparedForRuntimeRequest(prepared, request.customTools, request.signal, (value) => { structuredResult = value; }, resultTool), ...(request.turnControl ? { turnControl: request.turnControl } : {}) });
176
176
  const callbacks = this.#options.callbacks;
177
177
  const handoff = this.#options.handoff;
178
178
  let adapter: PiRuntimeSessionAdapter | undefined;
@@ -364,6 +364,8 @@ export class PiRuntimeAgentRunner implements RuntimeAgentRunner {
364
364
  throwIfProgressFailed();
365
365
  beginTurn(false);
366
366
  throwIfTurnPolicyFailed();
367
+ // NOTE: A transport that already sent turnControl (Herdr, in its pane launch prompt) reports turnControlDelivered; steering its suspended local session would fail.
368
+ if (request.turnControl && !session.turnControlDelivered) await activeSession.steer(request.turnControl);
367
369
  await promptAndRecover(request.task, true);
368
370
  completeTurn(hasResult() || !hasToolCall(lastAssistant), false, true);
369
371
  if (turnPolicyFailure) throw turnPolicyFailure;
package/src/registry.ts CHANGED
@@ -1,48 +1,16 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import { Value } from "typebox/value";
5
- import type { AgentAttemptAction, JsonSchema, JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogModelAlias, WorkflowExtension, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowFunction, WorkflowFunctionContext, WorkflowFunctionSource, WorkflowJournal, WorkflowModelAlias, WorkflowModelAliasResolverContext, WorkflowRoleDirectoryRegistration } from "./types.js";
2
+ import type { AgentAttemptAction, JsonSchema, JsonValue, RegisteredAgentSetupHook, RegisteredAgentPreparationHook, WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogModelAlias, WorkflowExtension, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowFunction, WorkflowFunctionContext, WorkflowFunctionSource, WorkflowJournal, WorkflowModelAlias, WorkflowModelAliasResolverContext } from "./types.js";
6
3
  import type { SubagentRunRequest, SubagentStatus } from "../subagents/src/contracts.js";
7
4
  import { byPriorityThenName, deepFreeze, errorCode, errorText, fail, jsonValue, MODEL_ALIAS_NAME, object, validWorkflowExtensionNamespace } from "./utils.js";
8
5
  import { loadSettings, resolveWorkflowSettings } from "./settings.js";
9
6
  import { validateSchema } from "./utils.js";
10
- import { canonicalPath } from "./paths.js";
11
- import { isCorePackageName } from "./package-identity.js";
12
7
 
13
- const RESERVED_GLOBALS = new Set(["agent", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
8
+ const RESERVED_GLOBALS = new Set(["agent", "shell", "tools", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
14
9
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
15
10
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
16
11
  const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
17
12
  export type SubagentStatusObserver = (status: Readonly<SubagentStatus>, request: Readonly<SubagentRunRequest>) => void;
18
13
 
19
- function normalizeRoleDirectory(value: unknown): string {
20
- try {
21
- if (value instanceof URL) {
22
- if (value.protocol !== "file:") fail("INVALID_METADATA", "Workflow role directories require file URLs or absolute filesystem paths");
23
- return fileURLToPath(value);
24
- }
25
- if (typeof value === "string") {
26
- if (value.startsWith("file:")) return fileURLToPath(value);
27
- if (isAbsolute(value)) return value;
28
- }
29
- } catch (error) {
30
- fail("INVALID_METADATA", `Invalid workflow role directory: ${errorText(error)}`);
31
- }
32
- fail("INVALID_METADATA", "Workflow role directories require file URLs or absolute filesystem paths");
33
- }
34
- function isBuiltinRoleDirectory(path: string): boolean {
35
- const canonical = canonicalPath(path);
36
- const starterDirectory = dirname(canonical);
37
- if (basename(canonical) !== "roles" || basename(starterDirectory) !== "starter") return false;
38
- const distributionDirectory = dirname(starterDirectory);
39
- const packageRoot = basename(distributionDirectory) === "dist" ? dirname(distributionDirectory) : distributionDirectory;
40
- try {
41
- const metadata: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
42
- return object(metadata) && isCorePackageName(metadata.name);
43
- } catch { return false; }
44
- }
45
-
46
14
  function catalogSchema(schema: unknown, at: string): JsonSchema {
47
15
  validateSchema(schema, at);
48
16
  return structuredClone(schema);
@@ -51,9 +19,9 @@ export class WorkflowRegistry {
51
19
  readonly #extensions = new Set<Readonly<WorkflowExtension>>();
52
20
  readonly #functionSources = new Map<string, WorkflowFunctionSource>();
53
21
  readonly #globals = new Map<string, string>();
22
+ readonly #preparationHooks = new Map<string, RegisteredAgentPreparationHook>();
54
23
  readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
55
24
  readonly #agentAttemptActions = new Map<string, AgentAttemptAction>();
56
- readonly #roleDirectories = new Map<string, WorkflowRoleDirectoryRegistration>();
57
25
  readonly #modelAliases = new Map<string, { name: string; version: string; headline: string; resolve: WorkflowModelAlias["resolve"] }>();
58
26
  readonly #settingsValidators = new Set<{ headline: string; validate: NonNullable<WorkflowExtension["validateSettings"]> }>();
59
27
  #frozen = false;
@@ -67,9 +35,10 @@ export class WorkflowRegistry {
67
35
  register(extension: WorkflowExtension): void {
68
36
  if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
69
37
  if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows")) fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
70
- if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "source", "dependencies", "validateSettings", "functions", "modelAliases", "agentSetupHooks", "agentAttemptActions", "roleDirectories"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version and non-empty headline");
38
+ if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "source", "dependencies", "validateSettings", "functions", "modelAliases", "agentPreparationHooks", "agentSetupHooks", "agentAttemptActions"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version and non-empty headline");
71
39
  const functions = extension.functions ?? {};
72
40
  const modelAliases = extension.modelAliases ?? {};
41
+ const agentPreparationHooks = extension.agentPreparationHooks ?? {};
73
42
  const agentSetupHooks = extension.agentSetupHooks ?? {};
74
43
  const agentAttemptActions = extension.agentAttemptActions ?? {};
75
44
  const source = extension.source;
@@ -79,10 +48,7 @@ export class WorkflowRegistry {
79
48
  if (validateSettings !== undefined && typeof validateSettings !== "function") fail("INVALID_METADATA", "Workflow extension validateSettings must be a function");
80
49
  if (source !== undefined && (typeof source !== "string" || !source.trim())) fail("INVALID_METADATA", "Workflow extension source must be a non-empty module URL");
81
50
  if (dependencyValues !== undefined && (!Array.isArray(dependencyValues) || dependencyValues.some((dependency) => typeof dependency !== "string" || dependency.trim() !== dependency || !PACKAGE_NAME.test(dependency)) || new Set(dependencyValues).size !== dependencyValues.length)) fail("INVALID_METADATA", "Workflow extension dependencies must be unique, non-empty package names");
82
- const roleDirectoryValues = extension.roleDirectories === undefined ? [] : extension.roleDirectories;
83
- if (!Array.isArray(roleDirectoryValues)) fail("INVALID_METADATA", "Workflow extension roleDirectories must be an array");
84
- const roleDirectories = [...new Set(Array.from(roleDirectoryValues, (value) => normalizeRoleDirectory(value)))];
85
- if (!object(functions) || !object(modelAliases) || !object(agentSetupHooks) || !object(agentAttemptActions) || (Object.keys(functions).length === 0 && Object.keys(modelAliases).length === 0 && Object.keys(agentSetupHooks).length === 0 && Object.keys(agentAttemptActions).length === 0 && roleDirectories.length === 0 && validateSettings === undefined)) fail("INVALID_METADATA", "Workflow extensions require functions, model aliases, agent setup hooks, agent attempt actions, role directories, or a settings validator");
51
+ if (!object(functions) || !object(modelAliases) || !object(agentPreparationHooks) || !object(agentSetupHooks) || !object(agentAttemptActions) || (Object.keys(functions).length === 0 && Object.keys(modelAliases).length === 0 && Object.keys(agentPreparationHooks).length === 0 && Object.keys(agentSetupHooks).length === 0 && Object.keys(agentAttemptActions).length === 0 && validateSettings === undefined)) fail("INVALID_METADATA", "Workflow extensions require functions, model aliases, agent preparation hooks, agent setup hooks, agent attempt actions, or a settings validator");
86
52
  const names = Object.keys(functions);
87
53
  if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
88
54
  for (const name of names) {
@@ -101,6 +67,11 @@ export class WorkflowRegistry {
101
67
  if (!object(alias) || Object.keys(alias).some((key) => key !== "resolve") || typeof alias.resolve !== "function") fail("INVALID_METADATA", `Invalid model alias resolver: ${name}`);
102
68
  if (this.#modelAliases.has(name)) fail("DUPLICATE_NAME", `Model alias already registered: ${name}`);
103
69
  }
70
+ for (const [name, hook] of Object.entries(agentPreparationHooks)) {
71
+ if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "optionsSchema", "prepare"].includes(key)) || typeof hook.prepare !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent preparation hook: ${name}`);
72
+ if (this.#preparationHooks.has(name)) fail("DUPLICATE_NAME", `Agent preparation hook already registered: ${name}`);
73
+ if (hook.optionsSchema !== undefined) validateSchema(hook.optionsSchema, `${name} options schema`);
74
+ }
104
75
  for (const [name, hook] of Object.entries(agentSetupHooks)) {
105
76
  if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
106
77
  if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
@@ -110,17 +81,22 @@ export class WorkflowRegistry {
110
81
  if ((action.visibleStandalone !== undefined) !== (action.runStandalone !== undefined)) fail("INVALID_METADATA", `Standalone agent attempt actions require visibleStandalone and runStandalone: ${name}`);
111
82
  if (this.#agentAttemptActions.has(name)) fail("DUPLICATE_NAME", `Agent attempt action already registered: ${name}`);
112
83
  }
113
- const stored = deepFreeze({ ...extension, functions, modelAliases, agentSetupHooks, agentAttemptActions, ...(roleDirectories.length ? { roleDirectories } : {}) });
84
+ const stored = deepFreeze({ ...extension, functions, modelAliases, agentPreparationHooks, agentSetupHooks, agentAttemptActions });
114
85
  if (source !== undefined) for (const name of names) this.#functionSources.set(name, Object.freeze({ module: source, export: "default", dependencies: Object.freeze([...dependencies]) }));
115
86
  this.#extensions.add(stored);
116
- for (const directory of roleDirectories) if (!this.#roleDirectories.has(directory)) this.#roleDirectories.set(directory, deepFreeze({ path: directory, extension: { version: extension.version, headline: extension.headline }, ...(isBuiltinRoleDirectory(directory) ? { builtin: true as const } : {}) }));
117
87
  for (const name of names) this.#globals.set(name, name);
118
88
  for (const [name, alias] of Object.entries(modelAliases)) this.#modelAliases.set(name, { name, version: extension.version, headline: extension.headline, resolve: alias.resolve });
89
+ for (const [name, hook] of Object.entries(agentPreparationHooks)) this.#preparationHooks.set(name, { name, priority: hook.priority ?? 10, prepare: hook.prepare, ...(hook.optionsSchema === undefined ? {} : { optionsSchema: structuredClone(hook.optionsSchema) }), ...(source === undefined ? {} : { source }) });
119
90
  for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
120
91
  for (const [name, action] of Object.entries(agentAttemptActions)) this.#agentAttemptActions.set(name, action);
121
92
  if (validateSettings !== undefined) this.#settingsValidators.add({ headline: extension.headline, validate: validateSettings });
122
93
  }
123
94
 
95
+ /** Every registration's headline and declared module provenance, for hosts that must prove which extension made it. */
96
+ registrations(): ReadonlyArray<Readonly<{ headline: string; source?: string }>> {
97
+ return [...this.#extensions].map(({ headline, source }) => Object.freeze({ headline, ...(source === undefined ? {} : { source }) }));
98
+ }
99
+
124
100
  function(name: string): WorkflowFunction {
125
101
  if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
126
102
  const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
@@ -145,7 +121,7 @@ export class WorkflowRegistry {
145
121
  let source = "global settings";
146
122
  try {
147
123
  const resolved = context ? resolveWorkflowSettings(context.cwd, context.projectTrusted, context.globalSettingsPath) : undefined;
148
- aliases = resolved?.effective.modelAliases ?? loadSettings().modelAliases;
124
+ aliases = resolved?.effective.modelAliases ?? (context ? loadSettings().modelAliases : resolveWorkflowSettings(process.cwd(), false).effective.modelAliases);
149
125
  if (resolved) { source = resolved.projectTrusted && [resolved.sources.modelAliases, resolved.sources.skills, resolved.sources.extensions, resolved.sources.tools].some((path) => path === resolved.projectSettingsPath) ? "trusted project settings" : "global settings"; settings = { concurrency: resolved.effective.concurrency, backgroundWidget: resolved.effective.backgroundWidget ?? true, modelAliases: resolved.effective.modelAliases ?? {}, skills: resolved.effective.skills ?? [], extensions: Array.isArray(resolved.effective.extensions) ? resolved.effective.extensions : [], tools: resolved.effective.tools ?? [], ...(resolved.effective.extensionSettings === undefined ? {} : { extensionSettings: resolved.effective.extensionSettings }), ...(resolved.effective.worktreePostCreateCommand === undefined ? {} : { worktreePostCreateCommand: resolved.effective.worktreePostCreateCommand }), globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted: resolved.projectTrusted, sources: resolved.sources }; }
150
126
  } catch { aliases = undefined; }
151
127
  const staticEntries: WorkflowCatalogModelAlias[] = Object.keys(aliases ?? {}).map((name) => ({ name, kind: "static", provenance: source }));
@@ -197,7 +173,7 @@ export class WorkflowRegistry {
197
173
  for (const { headline, validate } of this.#settingsValidators) {
198
174
  try { validate(frozenSettings, Object.freeze({ ...context })); }
199
175
  catch (error) {
200
- const location = context.source === "role" && context.role ? `role ${context.role}` : context.source;
176
+ const location = context.source;
201
177
  fail("INVALID_SETTINGS", `Invalid extension settings (${headline}; ${location}): ${errorText(error)}`);
202
178
  }
203
179
  }
@@ -217,16 +193,11 @@ export class WorkflowRegistry {
217
193
  return structuredClone(stored);
218
194
  }
219
195
 
196
+ agentPreparationHooks(): readonly RegisteredAgentPreparationHook[] { return [...this.#preparationHooks.values()].sort(byPriorityThenName); }
220
197
  agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
221
198
  return [...this.#hooks.values()].sort(byPriorityThenName);
222
199
  }
223
200
  agentAttemptActions(): Readonly<Record<string, AgentAttemptAction>> { return Object.freeze(Object.fromEntries(this.#agentAttemptActions.entries())); }
224
- roleDirectories(): readonly string[] {
225
- return [...this.#roleDirectories.keys()];
226
- }
227
- roleDirectoryRegistrations(): readonly WorkflowRoleDirectoryRegistration[] {
228
- return [...this.#roleDirectories.values()];
229
- }
230
201
  modelAliases(): readonly { name: string; version: string; headline: string; resolve: WorkflowModelAlias["resolve"] }[] { return [...this.#modelAliases.values()].sort((left, right) => left.name.localeCompare(right.name)); }
231
202
  async resolveModelAliases(context: Readonly<WorkflowModelAliasResolverContext>, shadowed: ReadonlySet<string> = new Set()): Promise<Readonly<Record<string, string>>> {
232
203
  const resolved: Record<string, string> = {};
@@ -247,7 +218,7 @@ export class WorkflowRegistry {
247
218
  return Object.freeze(resolved);
248
219
  }
249
220
  }
250
- export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "functionSources" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "validateExtensionSettings" | "modelAliases" | "resolveModelAliases" | "agentSetupHooks" | "agentAttemptActions" | "roleDirectories" | "roleDirectoryRegistrations" | "setSubagentStatusObserver" | "observeSubagentStatus">;
221
+ export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "functionSources" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "validateExtensionSettings" | "modelAliases" | "resolveModelAliases" | "agentPreparationHooks" | "agentSetupHooks" | "agentAttemptActions" | "setSubagentStatusObserver" | "observeSubagentStatus" | "registrations">;
251
222
  interface WorkflowRegistryHost { api: WorkflowRegistryApi; activeHosts: number }
252
223
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
253
224
  const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
@@ -269,10 +240,10 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
269
240
  validateExtensionSettings: (...args) => { registry.validateExtensionSettings(...args); },
270
241
  modelAliases: () => registry.modelAliases(),
271
242
  resolveModelAliases: (...args) => registry.resolveModelAliases(...args),
272
- roleDirectories: () => registry.roleDirectories(),
273
- roleDirectoryRegistrations: () => registry.roleDirectoryRegistrations(),
243
+ agentPreparationHooks: () => registry.agentPreparationHooks(),
274
244
  agentSetupHooks: () => registry.agentSetupHooks(),
275
245
  agentAttemptActions: () => registry.agentAttemptActions(),
246
+ registrations: () => registry.registrations(),
276
247
  };
277
248
  }
278
249
  function workflowRegistryHost(): WorkflowRegistryHost {
@@ -301,7 +272,9 @@ export function retainWorkflowRegistry(): () => void {
301
272
  }
302
273
  export function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().api; }
303
274
  beginWorkflowExtensionLoading();
304
- export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
275
+ export function registerWorkflowExtension(extension: WorkflowExtension): void {
276
+ loadingRegistry().register(extension);
277
+ }
305
278
  export function workflowCatalog(context?: WorkflowCatalogContext): WorkflowCatalog { return loadingRegistry().catalog(context); }
306
279
  export function workflowCatalogIndex(context?: WorkflowCatalogContext): WorkflowCatalogIndex { return loadingRegistry().catalogIndex(context); }
307
280
  export function workflowCatalogDetail(name: string, context?: WorkflowCatalogContext): WorkflowCatalogFunction | WorkflowCatalogModelAlias | WorkflowCatalogError { return loadingRegistry().catalogDetail(name, context); }
@@ -310,13 +283,4 @@ export function registeredWorkflowFunctionSources(): Readonly<Record<string, Wor
310
283
  const sources = loadingRegistry().functionSources;
311
284
  return typeof sources === "function" ? sources() : {};
312
285
  }
313
- export function registeredWorkflowRoleDirectories(): readonly string[] {
314
- const directories = loadingRegistry().roleDirectories;
315
- return typeof directories === "function" ? directories() : [];
316
- }
317
- export function registeredWorkflowRoleDirectoryRegistrations(): readonly WorkflowRoleDirectoryRegistration[] {
318
- const registrations = loadingRegistry().roleDirectoryRegistrations;
319
- return typeof registrations === "function" ? registrations() : [];
320
- }
321
-
322
- export type { WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogModelAlias, WorkflowCatalogSettings, WorkflowRoleDirectoryRegistration } from "./types.js";
286
+ export type { WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogModelAlias, WorkflowCatalogSettings } from "./types.js";
@@ -121,6 +121,8 @@ export interface RuntimeAgentRunRequest {
121
121
  readonly customTools: readonly RuntimeTool[];
122
122
  /** Explicit result schema; omitted agents use the default string workflow_result schema. */
123
123
  readonly resultSchema?: RuntimeJsonSchema;
124
+ /** Transient retry or budget text delivered as a separate steered message after the task, never inside it. */
125
+ readonly turnControl?: string;
124
126
  readonly run: RuntimeRunIdentity;
125
127
  readonly agent: RuntimeAgentIdentity;
126
128
  readonly signal: AbortSignal;
@@ -0,0 +1,76 @@
1
+ import type { ExtensionToolContext, ToolLoadout, ToolLoadoutChanges } from "@earendil-works/pi-coding-agent";
2
+ import { WorkflowError, type JsonValue } from "./types.js";
3
+ import { errorText, jsonValue, object } from "./utils.js";
4
+ import { RPC_LIMIT_BYTES } from "./execution.js";
5
+ import { scriptToolReferences } from "./validation.js";
6
+
7
+ type ScriptTool = ExtensionToolContext["tools"][number];
8
+ type ScriptToolOutcome = Awaited<ReturnType<ExtensionToolContext["executeTool"]>>;
9
+
10
+ /** Workflow and subagent tools stay out of scripts, so a run cannot launch or control runs. Codemode is model-only and never callable. */
11
+ export function scriptToolExcluded(name: string): boolean {
12
+ return name === "workflow" || name.startsWith("workflow_") || name.startsWith("subagents_");
13
+ }
14
+ /** The identifier scripts use for a tool, the same as Pi codemode's, so its `tools.<id>(args)` lines apply to workflow scripts. */
15
+ export function scriptToolIdentifier(name: string): string {
16
+ let identifier = "";
17
+ for (const char of name) identifier += (identifier === "" ? /^[A-Za-z_$]$/ : /^[A-Za-z0-9_$]$/).test(char) ? char : "_";
18
+ return identifier || "_";
19
+ }
20
+ /** The context of a Pi tool call, whose `executeTool()` runs session tools; absent for commands, session events, and headless launches. */
21
+ export function scriptToolContext(ctx: unknown): ExtensionToolContext | undefined {
22
+ return object(ctx) && typeof ctx.executeTool === "function" && Array.isArray(ctx.tools) ? ctx as unknown as ExtensionToolContext : undefined;
23
+ }
24
+ export function scriptToolIdentifiers(context: ExtensionToolContext | undefined): string[] {
25
+ return (context?.tools ?? []).filter(({ name }) => !scriptToolExcluded(name)).map(({ name }) => scriptToolIdentifier(name));
26
+ }
27
+ // NOTE: a launch rejects identifiers that several tools share; a later tool set may still collide, and the first match wins.
28
+ export function scriptTool(context: ExtensionToolContext, identifier: string): ScriptTool | undefined {
29
+ return context.tools.find(({ name }) => !scriptToolExcluded(name) && scriptToolIdentifier(name) === identifier);
30
+ }
31
+ /**
32
+ * What a script call resolves to, as in codemode: the structured content of a tool with an output
33
+ * schema, also for error results that carry one, otherwise its text; other failures throw. The value
34
+ * is normalized through JSON and size-checked before it is journaled, so a replay returns the same value.
35
+ */
36
+ export function scriptToolValue(tool: ScriptTool, outcome: ScriptToolOutcome): JsonValue {
37
+ const structured = tool.outputSchema === undefined ? undefined : outcome.result.structuredContent;
38
+ const text = outcome.result.content.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
39
+ if (structured === undefined && outcome.isError) throw new WorkflowError("TOOL_FAILED", `${tool.name}: ${text || "failed"}`);
40
+ let value: unknown;
41
+ try { value = JSON.parse(JSON.stringify(structured ?? text)) as unknown; } catch (error) { throw new WorkflowError("TOOL_FAILED", `${tool.name} returned a result that is not JSON: ${errorText(error)}`); }
42
+ if (!jsonValue(value)) throw new WorkflowError("TOOL_FAILED", `${tool.name} returned a result that is not JSON`);
43
+ if (Buffer.byteLength(JSON.stringify(value)) > RPC_LIMIT_BYTES) throw new WorkflowError("TOOL_FAILED", `${tool.name} returned more than the 10 MB workflow RPC limit`);
44
+ return value;
45
+ }
46
+ /** Fails a launch whose script names a tool this session cannot call, before any agent runs. */
47
+ export function validateScriptToolReferences(script: string, context: ExtensionToolContext | undefined): void {
48
+ const references = scriptToolReferences(script);
49
+ if (!references?.length) return;
50
+ if (!context) throw new WorkflowError("UNKNOWN_TOOL", `Workflow scripts can call session tools only when launched by the workflow tool; this launch has none for ${references.map((name) => `tools.${name}`).join(", ")}`);
51
+ const identifiers = scriptToolIdentifiers(context);
52
+ const unknown = references.filter((name) => !identifiers.includes(name));
53
+ if (unknown.length) throw new WorkflowError("UNKNOWN_TOOL", `Workflow script calls tools this session cannot call: ${unknown.map((name) => `tools.${name}`).join(", ")}`);
54
+ const ambiguous = references.filter((name) => identifiers.indexOf(name) !== identifiers.lastIndexOf(name));
55
+ if (ambiguous.length) throw new WorkflowError("UNKNOWN_TOOL", `Workflow script calls tools whose names collide after normalization: ${ambiguous.map((name) => `tools.${name}`).join(", ")}`);
56
+ }
57
+ function describeScriptOutput(schema: unknown): string | undefined {
58
+ if (!object(schema) || schema.type !== "object" || !object(schema.properties)) return schema === undefined ? undefined : "a JSON value";
59
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
60
+ return `\`{ ${Object.keys(schema.properties).map((name) => required.has(name) ? name : `${name}?`).join(", ")} }\``;
61
+ }
62
+ /**
63
+ * Tells the model what `tools.<id>(args)` resolves to for declared tools with an output schema; the
64
+ * workflow description says that the others resolve to their text. With codemode active its own
65
+ * identical `Codemode:` lines say this, and pi keeps only one description per tool, so this defers.
66
+ */
67
+ export function prepareScriptToolLoadout(loadout: ToolLoadout): ToolLoadoutChanges | undefined {
68
+ if (loadout.declared.some(({ name }) => name === "codemode")) return undefined;
69
+ const callable = new Set(loadout.callable.map(({ name }) => name));
70
+ const descriptions: Record<string, string> = {};
71
+ for (const tool of loadout.declared) {
72
+ const output = callable.has(tool.name) && !scriptToolExcluded(tool.name) ? describeScriptOutput(tool.outputSchema) : undefined;
73
+ if (output) descriptions[tool.name] = `${tool.description.trim()}\n\nWorkflow scripts: \`tools.${scriptToolIdentifier(tool.name)}(args)\` resolves to ${output}.`;
74
+ }
75
+ return { descriptions };
76
+ }
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { link, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -12,12 +13,39 @@ const SESSION_OWNER_FILE = "owner.json";
12
13
  const SESSION_OWNER_WRITE_GRACE_MS = 30_000;
13
14
  const RUN_CREATE_TEMP = /^\.([a-zA-Z0-9._-]+)\.(\d+)\.[0-9a-f-]+\.tmp$/;
14
15
 
15
- export async function processAlive(pid: number, startedAt?: number): Promise<boolean> {
16
+ const START_MARGIN_MS = 60_000;
17
+ let clockTicks: number | undefined;
18
+
19
+ function clockTickRate(): number {
20
+ if (clockTicks !== undefined) return clockTicks;
21
+ try {
22
+ const rate = Number(execFileSync("getconf", ["CLK_TCK"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
23
+ clockTicks = Number.isInteger(rate) && rate > 0 ? rate : 100;
24
+ } catch { clockTicks = 100; }
25
+ return clockTicks;
26
+ }
27
+
28
+ // Process start in epoch ms from /proc/<pid>/stat field 22; undefined when unreadable or unparsable.
29
+ async function linuxProcessStart(pid: number, procRoot: string): Promise<number | undefined> {
30
+ try {
31
+ const text = await readFile(join(procRoot, String(pid), "stat"), "utf8");
32
+ // Field 2 (command name) may contain spaces and parentheses, so count fields after the last ")".
33
+ const close = text.lastIndexOf(")");
34
+ const ticks = close < 0 ? Number.NaN : Number(text.slice(close + 2).split(" ")[19]);
35
+ const btime = /^btime (\d+)$/m.exec(await readFile(join(procRoot, "stat"), "utf8"))?.[1];
36
+ if (btime === undefined || !Number.isInteger(ticks) || ticks < 0) return undefined;
37
+ return Number(btime) * 1000 + (ticks / clockTickRate()) * 1000;
38
+ } catch { return undefined; }
39
+ }
40
+
41
+ // The /proc/<pid> ctime is not a start time: procfs rebuilds the inode after cache reclaim.
42
+ // A live owner never starts after its stamp; a reused pid starts later.
43
+ export async function processAlive(pid: number, startedAt?: number, procRoot = "/proc"): Promise<boolean> {
44
+ try { process.kill(pid, 0); } catch (error) { return !isNodeError(error, "ESRCH"); }
45
+ if (startedAt === undefined || process.platform !== "linux") return true;
46
+ const start = await linuxProcessStart(pid, procRoot);
47
+ if (start !== undefined) return start <= startedAt + START_MARGIN_MS;
16
48
  try { process.kill(pid, 0); } catch (error) { return !isNodeError(error, "ESRCH"); }
17
- if (startedAt !== undefined && process.platform === "linux") {
18
- try { if ((await stat(`/proc/${String(pid)}`)).ctimeMs > startedAt) return false; }
19
- catch (error) { if (isNodeError(error, "ENOENT")) return false; }
20
- }
21
49
  return true;
22
50
  }
23
51
  export async function hasLiveSessionLease(cwd: string, sessionId: string, home = homedir()): Promise<boolean> {
@@ -73,7 +101,7 @@ export async function acquireSessionLease(cwd: string, sessionId: string, home =
73
101
  const path = join(directory, SESSION_OWNER_FILE);
74
102
  for (;;) {
75
103
  const token = randomUUID();
76
- const owner: SessionOwner = { pid: process.pid, token, startedAt: process.platform === "linux" ? (await stat(`/proc/${String(process.pid)}`)).ctimeMs : Date.now() };
104
+ const owner: SessionOwner = { pid: process.pid, token, startedAt: Date.now() };
77
105
  try {
78
106
  const handle = await open(path, "wx", 0o600);
79
107
  try { await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8"); } finally { await handle.close(); }