one-code-extension 0.1.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.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow extension — Claude Code's Workflow ("ultracode") tool.
|
|
3
|
+
*
|
|
4
|
+
* The model authors a small JavaScript orchestration script (leading
|
|
5
|
+
* `export const meta = {...}`, then agent()/parallel()/pipeline()/phase()/
|
|
6
|
+
* log()/args/budget) that fans work out across many in-process subagents.
|
|
7
|
+
* Runs go to the background by default: the tool returns a runId immediately
|
|
8
|
+
* and the result arrives later as a follow-up message. Scripts and per-call
|
|
9
|
+
* journals persist under the session dir, so `resumeFromRunId` replays the
|
|
10
|
+
* unchanged prefix of an edited or interrupted run. Saved workflows live in
|
|
11
|
+
* `.claude/workflows/` (project) and `~/.claude/workflows/` (user).
|
|
12
|
+
*
|
|
13
|
+
* Orchestration is opt-in, like Claude Code: the tool description gates it,
|
|
14
|
+
* and the literal keyword "ultracode" in a user message arms the turn via a
|
|
15
|
+
* system reminder.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
24
|
+
import { applicableSubagentDefault, loadSubagentDefault } from "../subagents/default-model.ts";
|
|
25
|
+
import { discoverSavedWorkflows, findSavedWorkflow } from "./saved-workflows.ts";
|
|
26
|
+
import { buildRunReport, WorkflowRunManager } from "./run-manager.ts";
|
|
27
|
+
import { ccToolRenderers, customMessageText, notificationComponent } from "../lib/tui-render.ts";
|
|
28
|
+
import { WorkflowScriptError } from "./types.ts";
|
|
29
|
+
import { WorkflowWidget } from "./widget.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Claude Code's own arming reminder, verbatim in intent: the keyword is a
|
|
33
|
+
* standing opt-in for the turn, delivered as a system-reminder rather than
|
|
34
|
+
* anything the model has to notice on its own.
|
|
35
|
+
*/
|
|
36
|
+
const ULTRACODE_REMINDER =
|
|
37
|
+
'The user included the keyword "ultracode", opting this turn into multi-agent orchestration — use the workflow tool to fulfill the request.';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ported from Claude Code's Workflow tool description, adapted to this harness
|
|
41
|
+
* (runId + follow-up message instead of a task id + task-notification,
|
|
42
|
+
* `/workflows stop` instead of TaskStop, `subagent` as the single-agent
|
|
43
|
+
* alternative). The authoring guidance is the substantive part: it is what
|
|
44
|
+
* makes the model reach for pipeline() over barriers and pick sane fan-out
|
|
45
|
+
* sizes, so it is kept rather than trimmed.
|
|
46
|
+
*/
|
|
47
|
+
const WORKFLOW_TOOL_DESCRIPTION = `Execute a workflow script that orchestrates multiple subagents deterministically. Workflows run in the background — this tool returns immediately with a runId, and a follow-up message arrives when the workflow completes. Use /workflows to watch live progress.
|
|
48
|
+
|
|
49
|
+
A workflow structures work across many agents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before committing), or to take on scale one context can't hold (migrations, audits, broad sweeps). The script is where you encode that structure: what fans out, what verifies, what synthesizes.
|
|
50
|
+
|
|
51
|
+
ONLY call this tool when the user has explicitly opted into multi-agent orchestration. Workflows can spawn dozens of agents and consume a large amount of tokens; the user must request that scale, not have it inferred. Explicit opt-in means one of:
|
|
52
|
+
- The user included the keyword "ultracode" in their prompt (you'll see a system-reminder confirming it).
|
|
53
|
+
- The user directly asked you to run a workflow or use multi-agent orchestration in their own words ("use a workflow", "run a workflow", "fan out agents", "orchestrate this with subagents"). The ask must be in the user's words — a task that would merely benefit from a workflow does not count.
|
|
54
|
+
- The user asked you to run a specific named or saved workflow.
|
|
55
|
+
|
|
56
|
+
For any other task — even one that would clearly benefit from parallelism — do NOT call this tool. Use the \`subagent\` tool for individual subagents, or briefly describe what a multi-agent workflow could do and ask the user whether to run it.
|
|
57
|
+
|
|
58
|
+
When you do call it, the right move is often **hybrid**: scout inline first (list the files, find the targets, scope the diff) to discover the work-list, then call workflow to pipeline over it. You don't need to know the shape before the *task* — only before the *orchestration step*.
|
|
59
|
+
|
|
60
|
+
Common single-phase workflows you can chain across turns:
|
|
61
|
+
- **Understand** — parallel readers over relevant subsystems → structured map
|
|
62
|
+
- **Design** — judge panel of N independent approaches → scored synthesis
|
|
63
|
+
- **Review** — dimensions → find → adversarially verify (example below)
|
|
64
|
+
- **Research** — multi-modal sweep → deep-read → synthesize
|
|
65
|
+
- **Migrate** — discover sites → transform each (worktree isolation) → verify
|
|
66
|
+
|
|
67
|
+
Pass the script inline via \`script\`. Every invocation persists its script under the run directory and returns the path in the tool result. To iterate on a workflow, edit that file and re-invoke with \`{scriptPath, resumeFromRunId}\` instead of resending the full script.
|
|
68
|
+
|
|
69
|
+
Every script must begin with \`export const meta = {...}\`:
|
|
70
|
+
export const meta = {
|
|
71
|
+
name: 'find-flaky-tests',
|
|
72
|
+
description: 'Find flaky tests and propose fixes', // one-line summary
|
|
73
|
+
phases: [ // one entry per phase() call
|
|
74
|
+
{ title: 'Scan', detail: 'grep test logs for retries' },
|
|
75
|
+
{ title: 'Fix', detail: 'one agent per flaky test' },
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
// script body starts here — use agent()/parallel()/pipeline()/phase()/log()
|
|
79
|
+
phase('Scan')
|
|
80
|
+
const flaky = await agent('grep CI logs for retry markers', {schema: FLAKY_SCHEMA})
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
The \`meta\` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation. Required fields: \`name\`, \`description\`. Optional: \`whenToUse\`, \`phases\`. Use the SAME phase titles in meta.phases as in phase() calls.
|
|
84
|
+
|
|
85
|
+
Script body hooks:
|
|
86
|
+
- agent(prompt: string, opts?: {label?, phase?, schema?, model?, allowExpensive?, effort?, isolation?: 'worktree', agentType?}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema object), the subagent is forced to call a structured_output tool and agent() returns the validated object — no parsing needed. Returns null if the subagent fails (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state). opts.model overrides the model — default to omitting it, the agent uses the effective /subagent default (configured, automatic smaller profile, then session fallback); a model costing more than the session model is rejected unless opts.allowExpensive is true (set it only when the user explicitly asked for that model). opts.effort overrides reasoning effort ('off' | 'minimal' | 'low' | 'medium' | 'high'). opts.isolation: 'worktree' runs the agent in a fresh git worktree — use ONLY when agents mutate files in parallel and would otherwise conflict. opts.agentType uses a named agent from .claude/agents/.
|
|
87
|
+
- parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — run tasks concurrently. This is a BARRIER: awaits all thunks before returning. A thunk that throws resolves to \`null\` — the call itself never rejects, so \`.filter(Boolean)\` before using the results. Use ONLY when you genuinely need all results together.
|
|
88
|
+
- pipeline(items, stage1, stage2, ...): Promise<any[]> — run each item through all stages independently, NO barrier between stages. Item A can be in stage 3 while item B is still in stage 1. This is the DEFAULT for multi-stage work. Wall-clock = slowest single-item chain, not sum-of-slowest-per-stage. Every stage callback receives (prevResult, originalItem, index). A stage that throws drops that item to \`null\` and skips its remaining stages.
|
|
89
|
+
- log(message: string): void — emit a progress message to the user.
|
|
90
|
+
- phase(title: string): void — start a new phase; subsequent agent() calls are grouped under it.
|
|
91
|
+
- args: any — the value passed as this tool's \`args\` input, verbatim. Pass arrays/objects as actual JSON values, NOT as a JSON-encoded string.
|
|
92
|
+
- budget: {total: number|null, spent(): number, remaining(): number} — the run's token target from \`tokenBudget\`. \`total\` is null if none was set. The target is a HARD ceiling: once spent() reaches total, further agent() calls throw. Use for dynamic loops: \`while (budget.total && budget.remaining() > 50_000) { ... }\`.
|
|
93
|
+
- workflow(nameOrRef: string | {scriptPath: string}, args?: any): Promise<any> — run another workflow inline as a sub-step. Nesting is one level only.
|
|
94
|
+
|
|
95
|
+
DEFAULT TO pipeline(). Only reach for a barrier (parallel between stages) when you genuinely need ALL prior-stage results together — dedup/merge across the full result set, early-exit on a zero count, or a prompt that references "the other findings". A barrier is NOT justified by "I need to flatten/map/filter first" (do it inside a pipeline stage) or "the stages are conceptually separate".
|
|
96
|
+
|
|
97
|
+
Smell test: if you wrote
|
|
98
|
+
const a = await parallel(...)
|
|
99
|
+
const b = transform(a) // flatten, map, filter — no cross-item dependency
|
|
100
|
+
const c = await parallel(b.map(...))
|
|
101
|
+
that middle transform doesn't need the barrier. Rewrite as a pipeline with the transform inside a stage. When in doubt: pipeline.
|
|
102
|
+
|
|
103
|
+
Concurrent agent() calls are capped at min(16, cpu cores - 2) per workflow — excess calls queue and run as slots free up. Total agent count across a run is capped at 1000. A single parallel()/pipeline() call accepts at most 4096 items.
|
|
104
|
+
|
|
105
|
+
The canonical multi-stage pattern — pipeline by default, each dimension verifies as soon as its review completes:
|
|
106
|
+
export const meta = {
|
|
107
|
+
name: 'review-changes',
|
|
108
|
+
description: 'Review changed files across dimensions, verify each finding',
|
|
109
|
+
phases: [{ title: 'Review' }, { title: 'Verify' }],
|
|
110
|
+
}
|
|
111
|
+
const DIMENSIONS = [{key: 'bugs', prompt: '...'}, {key: 'perf', prompt: '...'}]
|
|
112
|
+
const results = await pipeline(
|
|
113
|
+
DIMENSIONS,
|
|
114
|
+
d => agent(d.prompt, {label: \`review:\${d.key}\`, phase: 'Review', schema: FINDINGS_SCHEMA}),
|
|
115
|
+
review => parallel(review.findings.map(f => () =>
|
|
116
|
+
agent(\`Adversarially verify: \${f.title}\`, {label: \`verify:\${f.file}\`, phase: 'Verify', schema: VERDICT_SCHEMA})
|
|
117
|
+
.then(v => ({...f, verdict: v}))
|
|
118
|
+
))
|
|
119
|
+
)
|
|
120
|
+
return { confirmed: results.flat().filter(Boolean).filter(f => f.verdict?.isReal) }
|
|
121
|
+
|
|
122
|
+
Quality patterns — common shapes; pick by task and compose freely:
|
|
123
|
+
- Adversarial verify: spawn N independent skeptics per finding, each prompted to REFUTE. Kill if ≥majority refute. Prevents plausible-but-wrong findings from surviving.
|
|
124
|
+
- Perspective-diverse verify: when a finding can fail in more than one way, give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters.
|
|
125
|
+
- Judge panel: generate N independent attempts from different angles, score with parallel judges, synthesize from the winner while grafting the best ideas from runners-up.
|
|
126
|
+
- Loop-until-dry: for unknown-size discovery, keep spawning finders until K consecutive rounds return nothing new. Dedup against everything seen, not just what was confirmed, or it never converges.
|
|
127
|
+
- Multi-modal sweep: parallel agents each searching a different way (by-container, by-content, by-entity, by-time).
|
|
128
|
+
- Completeness critic: a final agent that asks "what's missing — modality not run, claim unverified, source unread?"
|
|
129
|
+
- No silent caps: if a workflow bounds coverage (top-N, no-retry, sampling), log() what was dropped.
|
|
130
|
+
|
|
131
|
+
Scale to what the user asked for. "find any bugs" → a few finders, single-vote verify. "thoroughly audit this" → larger finder pool, 3–5 vote adversarial pass, synthesis stage. Keep workflows under 15 agents unless the user's prompt calls for a different scale.
|
|
132
|
+
|
|
133
|
+
Scripts are plain JavaScript, NOT TypeScript — type annotations, interfaces, and generics fail to parse. The script body runs in an async context — use await directly, and a bare top-level \`return\` sets the workflow's result. Standard JS built-ins are available — EXCEPT \`Date.now()\`/\`Math.random()\`/argless \`new Date()\`, which throw (they would break resume); pass timestamps in via \`args\`, and for randomness vary the agent prompt/label by index. No filesystem or shell access from the script itself — only via spawned agents.
|
|
134
|
+
|
|
135
|
+
Resume: to continue after a stop, kill, or script edit, relaunch with \`{scriptPath, resumeFromRunId}\` — the longest unchanged prefix of agent() calls returns cached results instantly; the first edited/new call and everything after it runs live. Same script + same args → 100% cache hit. Stop a live run first (\`/workflows stop <runId>\`) before resuming it. Background runs do not survive a session switch or /reload — resume them instead.
|
|
136
|
+
|
|
137
|
+
Run a saved workflow from \`.claude/workflows/\` (project) or \`~/.claude/workflows/\` (personal) by passing its \`name\`.`;
|
|
138
|
+
|
|
139
|
+
const WorkflowParams = Type.Object({
|
|
140
|
+
script: Type.Optional(
|
|
141
|
+
Type.String({
|
|
142
|
+
description:
|
|
143
|
+
"Self-contained workflow script. Must begin with `export const meta = { name, description, phases? }` (pure literal), followed by plain JavaScript using agent()/parallel()/pipeline()/phase()/log()/args/budget",
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
scriptPath: Type.Optional(
|
|
147
|
+
Type.String({
|
|
148
|
+
description:
|
|
149
|
+
"Path to a workflow script file on disk. Every run persists its script and returns the path — edit it and re-invoke with scriptPath (+ resumeFromRunId) to iterate",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
name: Type.Optional(
|
|
153
|
+
Type.String({ description: "Name of a saved workflow from .claude/workflows/ or ~/.claude/workflows/" }),
|
|
154
|
+
),
|
|
155
|
+
args: Type.Optional(
|
|
156
|
+
Type.Any({ description: "Value exposed to the script as the global `args`, verbatim. Pass real JSON values, not stringified JSON" }),
|
|
157
|
+
),
|
|
158
|
+
resumeFromRunId: Type.Optional(
|
|
159
|
+
Type.String({
|
|
160
|
+
pattern: "^wf_[a-z0-9-]{6,}$",
|
|
161
|
+
description:
|
|
162
|
+
"Run ID of a prior workflow invocation to resume from. Completed agent() calls with unchanged (prompt, opts) return their cached results instantly; only edited or new calls re-run. Stop the prior run first (/workflows stop) before resuming",
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
tokenBudget: Type.Optional(
|
|
166
|
+
Type.Integer({
|
|
167
|
+
minimum: 1000,
|
|
168
|
+
description: "Hard output-token ceiling for the run, exposed to the script as budget {total, spent(), remaining()}",
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
sync: Type.Optional(
|
|
172
|
+
Type.Boolean({ description: "Run in the foreground, blocking until the workflow finishes (default: background)" }),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export default function workflowExtension(pi: ExtensionAPI) {
|
|
177
|
+
const manager = new WorkflowRunManager();
|
|
178
|
+
let lastCtx: ExtensionContext | undefined;
|
|
179
|
+
const widget = new WorkflowWidget(manager, () => lastCtx);
|
|
180
|
+
const deliveredRuns = new Set<string>();
|
|
181
|
+
|
|
182
|
+
const deliverResult = (runId: string) => {
|
|
183
|
+
const handle = manager.get(runId);
|
|
184
|
+
if (!handle || deliveredRuns.has(runId)) return;
|
|
185
|
+
deliveredRuns.add(runId);
|
|
186
|
+
pi.sendMessage(
|
|
187
|
+
{
|
|
188
|
+
customType: "workflow-result",
|
|
189
|
+
content: [{ type: "text", text: buildRunReport(handle) }],
|
|
190
|
+
display: true,
|
|
191
|
+
details: { runId: handle.runId, name: handle.meta.name, status: handle.status },
|
|
192
|
+
},
|
|
193
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) =>
|
|
198
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
pi.registerTool({
|
|
202
|
+
name: "workflow",
|
|
203
|
+
label: "Workflow",
|
|
204
|
+
...ccToolRenderers<{ name?: string; scriptPath?: string; script?: string; resumeFromRunId?: string }>("Workflow", {
|
|
205
|
+
title: (a) => a?.name ?? a?.scriptPath ?? (a?.resumeFromRunId ? `resume ${a.resumeFromRunId}` : a?.script ? "inline script" : undefined),
|
|
206
|
+
}),
|
|
207
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
208
|
+
promptSnippet: "Run a script that orchestrates many subagents (opt-in ultracode mode)",
|
|
209
|
+
parameters: WorkflowParams,
|
|
210
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
211
|
+
lastCtx = ctx;
|
|
212
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
213
|
+
|
|
214
|
+
let script: string | undefined = params.script;
|
|
215
|
+
try {
|
|
216
|
+
if (!script && params.scriptPath) {
|
|
217
|
+
if (!existsSync(params.scriptPath)) throw new WorkflowScriptError(`scriptPath ${params.scriptPath} does not exist`);
|
|
218
|
+
script = readFileSync(params.scriptPath, "utf8");
|
|
219
|
+
}
|
|
220
|
+
if (!script && params.name) {
|
|
221
|
+
const saved = findSavedWorkflow(ctx.cwd, os.homedir(), params.name);
|
|
222
|
+
if (!saved) {
|
|
223
|
+
const known = discoverSavedWorkflows(ctx.cwd, os.homedir()).map((w) => w.name);
|
|
224
|
+
throw new WorkflowScriptError(
|
|
225
|
+
`No saved workflow named "${params.name}". Available: ${known.join(", ") || "(none)"}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
script = readFileSync(saved.path, "utf8");
|
|
229
|
+
}
|
|
230
|
+
if (!script && params.resumeFromRunId) {
|
|
231
|
+
const storedScript = join(sessionDir, "workflows", params.resumeFromRunId, "script.js");
|
|
232
|
+
if (!existsSync(storedScript)) {
|
|
233
|
+
throw new WorkflowScriptError(`resumeFromRunId ${params.resumeFromRunId} has no stored script`);
|
|
234
|
+
}
|
|
235
|
+
script = readFileSync(storedScript, "utf8");
|
|
236
|
+
}
|
|
237
|
+
if (!script) {
|
|
238
|
+
throw new WorkflowScriptError("Pass one of: script, scriptPath, name, or resumeFromRunId");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
242
|
+
const handle = manager.start({
|
|
243
|
+
script,
|
|
244
|
+
args: params.args,
|
|
245
|
+
tokenBudget: params.tokenBudget ?? null,
|
|
246
|
+
resumeFromRunId: params.resumeFromRunId,
|
|
247
|
+
cwd: ctx.cwd,
|
|
248
|
+
sessionDir,
|
|
249
|
+
defaultModel: ctx.model,
|
|
250
|
+
configuredDefaultModel: configuredDefault?.spec,
|
|
251
|
+
defaultEffort: ctx.thinkingLevel,
|
|
252
|
+
});
|
|
253
|
+
widget.attach(handle);
|
|
254
|
+
|
|
255
|
+
if (params.sync) {
|
|
256
|
+
const onProgress = () => {
|
|
257
|
+
onUpdate?.({
|
|
258
|
+
content: [{ type: "text", text: handle.recentEvents.slice(-8).join("\n") || "starting…" }],
|
|
259
|
+
details: { runId: handle.runId },
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
handle.on("progress", onProgress);
|
|
263
|
+
const onAbort = () => handle.abort("tool call aborted");
|
|
264
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
265
|
+
try {
|
|
266
|
+
await handle.finished;
|
|
267
|
+
} finally {
|
|
268
|
+
signal?.removeEventListener("abort", onAbort);
|
|
269
|
+
handle.removeListener("progress", onProgress);
|
|
270
|
+
}
|
|
271
|
+
deliveredRuns.add(handle.runId); // sync result goes in the tool result, not a followUp
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: buildRunReport(handle) }],
|
|
274
|
+
details: { runId: handle.runId, status: handle.status, scriptPath: handle.scriptPath },
|
|
275
|
+
isError: handle.status !== "completed",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
void handle.finished.then(() => deliverResult(handle.runId));
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text:
|
|
285
|
+
`Workflow **${handle.meta.name}** ${handle.resumed ? "resumed" : "started"} in the background.\n` +
|
|
286
|
+
`runId: ${handle.runId}\nscript: ${handle.scriptPath}\n` +
|
|
287
|
+
"The result will arrive as a follow-up message when the run finishes. " +
|
|
288
|
+
"You know nothing about its outcome until then — do not predict it. " +
|
|
289
|
+
"The user can watch with /workflows and stop with /workflows stop.",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
details: { runId: handle.runId, background: true, scriptPath: handle.scriptPath },
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return {
|
|
296
|
+
content: [{ type: "text", text: (error as Error).message }],
|
|
297
|
+
details: {},
|
|
298
|
+
isError: true,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.registerCommand("workflows", {
|
|
305
|
+
description: "List workflow runs and saved workflows; stop or inspect runs",
|
|
306
|
+
getArgumentCompletions: (prefix) => {
|
|
307
|
+
const items = ["stop ", "log "].filter((c) => c.startsWith(prefix));
|
|
308
|
+
return items.length ? items.map((c) => ({ value: c, label: c.trim() })) : null;
|
|
309
|
+
},
|
|
310
|
+
handler: async (args, ctx) => {
|
|
311
|
+
lastCtx = ctx;
|
|
312
|
+
const [action, runId] = args.trim().split(/\s+/);
|
|
313
|
+
if (action === "stop" && runId) {
|
|
314
|
+
const stopped = manager.abort(runId, "stopped via /workflows");
|
|
315
|
+
ctx.ui.notify(stopped ? `Stopping ${runId}…` : `No running workflow ${runId}`, stopped ? "info" : "error");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (action === "log" && runId) {
|
|
319
|
+
const handle = manager.get(runId);
|
|
320
|
+
if (!handle) {
|
|
321
|
+
ctx.ui.notify(`No workflow run ${runId} in this session`, "error");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
ctx.ui.notify(`${handle.meta.name} (${handle.status})\n${handle.recentEvents.join("\n") || "(no events)"}`, "info");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const lines: string[] = [];
|
|
329
|
+
const runs = manager.list();
|
|
330
|
+
if (runs.length) {
|
|
331
|
+
lines.push("Runs this session:");
|
|
332
|
+
for (const h of runs) {
|
|
333
|
+
const state = h.state;
|
|
334
|
+
lines.push(
|
|
335
|
+
` ${h.runId} ${h.meta.name} — ${h.status}${state ? ` (${state.agentCount()} agents, ${state.outputTokens()} out-tokens)` : ""}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const saved = discoverSavedWorkflows(ctx.cwd, os.homedir());
|
|
340
|
+
if (saved.length) {
|
|
341
|
+
lines.push("Saved workflows:");
|
|
342
|
+
for (const w of saved) lines.push(` ${w.name} (${w.source}) — ${w.meta?.description ?? w.path}`);
|
|
343
|
+
}
|
|
344
|
+
if (!lines.length) lines.push("No workflow runs yet and no saved workflows found (.claude/workflows/).");
|
|
345
|
+
lines.push("Usage: /workflows [stop <runId> | log <runId>]");
|
|
346
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
pi.on("input", (event) => {
|
|
351
|
+
if (/\bultracode\b/i.test(event.text)) {
|
|
352
|
+
pi.events.emit(REMINDER_CHANNEL, { text: ULTRACODE_REMINDER, scope: "next-turn" });
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
pi.on("session_start", (_event, ctx) => {
|
|
358
|
+
lastCtx = ctx;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.on("session_shutdown", () => {
|
|
362
|
+
manager.abortAll("session ended");
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run journal (pure). Every completed agent() call is appended to
|
|
3
|
+
* journal.jsonl as `{callIndex, hash, result, timestamp}`; on resume the
|
|
4
|
+
* longest prefix of calls whose positional index AND hash still match replays
|
|
5
|
+
* from the journal at zero cost, and the first mismatch switches the run to
|
|
6
|
+
* live mode for good (Claude Code's longest-unchanged-prefix rule).
|
|
7
|
+
*
|
|
8
|
+
* The hash covers exactly the inputs that change what an agent would do
|
|
9
|
+
* (prompt, model, effort, schema, agentType, isolation) and deliberately
|
|
10
|
+
* excludes cosmetic options (label, phase) so renaming a progress group does
|
|
11
|
+
* not invalidate a replay.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import type { AgentCallOptions, AgentCallResult, JournalEntry } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export function hashAgentCall(prompt: string, opts: AgentCallOptions): string {
|
|
19
|
+
const canonical = JSON.stringify({
|
|
20
|
+
prompt,
|
|
21
|
+
model: opts.model ?? null,
|
|
22
|
+
effort: opts.effort ?? null,
|
|
23
|
+
schema: opts.schema ?? null,
|
|
24
|
+
agentType: opts.agentType ?? null,
|
|
25
|
+
isolation: opts.isolation ?? null,
|
|
26
|
+
});
|
|
27
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readJournal(path: string): JournalEntry[] {
|
|
31
|
+
if (!existsSync(path)) return [];
|
|
32
|
+
const entries: JournalEntry[] = [];
|
|
33
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
try {
|
|
36
|
+
const entry = JSON.parse(line) as JournalEntry;
|
|
37
|
+
if (typeof entry.callIndex === "number" && typeof entry.hash === "string" && entry.result) {
|
|
38
|
+
entries.push(entry);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// A torn final line (killed mid-write) is expected; ignore it.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
entries.sort((a, b) => a.callIndex - b.callIndex);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function appendJournal(path: string, entry: JournalEntry): void {
|
|
49
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Replay cursor over a prior run's journal. `match()` consumes entries in
|
|
54
|
+
* positional order; the first miss (index gap, hash change, or exhausted
|
|
55
|
+
* journal) permanently switches to live mode.
|
|
56
|
+
*/
|
|
57
|
+
export class ReplayCursor {
|
|
58
|
+
private byIndex: Map<number, JournalEntry>;
|
|
59
|
+
private live = false;
|
|
60
|
+
|
|
61
|
+
constructor(entries: JournalEntry[]) {
|
|
62
|
+
this.byIndex = new Map(entries.map((e) => [e.callIndex, e]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Journaled result for this call, or undefined once the prefix is broken. */
|
|
66
|
+
match(callIndex: number, hash: string): AgentCallResult | undefined {
|
|
67
|
+
if (this.live) return undefined;
|
|
68
|
+
const entry = this.byIndex.get(callIndex);
|
|
69
|
+
if (!entry || entry.hash !== hash) {
|
|
70
|
+
this.live = true;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return entry.result;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate for workflow subagents (pure).
|
|
3
|
+
*
|
|
4
|
+
* Workflow subagent sessions are built with `noExtensions: true`, so One Code's
|
|
5
|
+
* own permissions extension never loads inside them — without this gate every
|
|
6
|
+
* subagent bash/edit/write would run unchecked. `DefaultResourceLoader` always
|
|
7
|
+
* loads explicitly passed `extensionFactories` (even under noExtensions),
|
|
8
|
+
* which is where this inline factory attaches.
|
|
9
|
+
*
|
|
10
|
+
* Policy: deny rules always win; explicit allow rules allow; edits are
|
|
11
|
+
* auto-allowed (Claude Code runs workflow subagents in acceptEdits); anything
|
|
12
|
+
* that would normally *ask* is denied — there is no interactive prompt inside
|
|
13
|
+
* an agent() call, and fail-closed beats silently trusting the model.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { findGitRoot } from "../lib/git.ts";
|
|
18
|
+
import { memoryDir } from "../lib/memory.ts";
|
|
19
|
+
import { sessionScratchpadDir } from "../lib/scratchpad.ts";
|
|
20
|
+
import { decide, extractSubject, normalizeToolName, parseRules } from "../permissions/matcher.ts";
|
|
21
|
+
import { loadPermissionSettings } from "../permissions/settings.ts";
|
|
22
|
+
|
|
23
|
+
/** Tools the workflow runtime itself injects into subagents; never gate them. */
|
|
24
|
+
const WORKFLOW_INTERNAL_TOOLS = new Set(["structured_output"]);
|
|
25
|
+
|
|
26
|
+
export function permissionGateFactory(cwd: string, home: string): InlineExtension {
|
|
27
|
+
const settings = loadPermissionSettings(cwd, home);
|
|
28
|
+
const deny = parseRules(settings.deny);
|
|
29
|
+
const allow = parseRules(settings.allow);
|
|
30
|
+
const mode = settings.defaultMode === "bypassPermissions" ? "bypassPermissions" : "acceptEdits";
|
|
31
|
+
// Memory writes work inside agent() calls too — otherwise the protected
|
|
32
|
+
// `.claude` check turns them into "needs interactive approval" and the
|
|
33
|
+
// harness blocks its own feature (same rationale as in decide()).
|
|
34
|
+
const memoryDirPath = memoryDir(home, findGitRoot(cwd) ?? cwd);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name: "workflow-permission-gate",
|
|
38
|
+
hidden: true,
|
|
39
|
+
factory: (pi) => {
|
|
40
|
+
// The scratchpad embeds the *child's* session id, which does not exist
|
|
41
|
+
// until the session runs — derived on first tool call, then pinned.
|
|
42
|
+
let scratchpadDirPath: string | undefined;
|
|
43
|
+
pi.on("tool_call", (event, ctx) => {
|
|
44
|
+
if (WORKFLOW_INTERNAL_TOOLS.has(event.toolName)) return undefined;
|
|
45
|
+
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
46
|
+
if (!scratchpadDirPath && sessionId) scratchpadDirPath = sessionScratchpadDir(cwd, sessionId);
|
|
47
|
+
const subject = extractSubject(normalizeToolName(event.toolName), event.input as Record<string, unknown>);
|
|
48
|
+
const result = decide({
|
|
49
|
+
toolName: event.toolName,
|
|
50
|
+
subject,
|
|
51
|
+
cwd,
|
|
52
|
+
mode,
|
|
53
|
+
deny,
|
|
54
|
+
ask: [],
|
|
55
|
+
allow,
|
|
56
|
+
memoryDirPath,
|
|
57
|
+
scratchpadDirPath,
|
|
58
|
+
});
|
|
59
|
+
if (result.decision === "allow") return undefined;
|
|
60
|
+
const ruleNote = result.rule ? ` (rule: ${result.rule.raw})` : "";
|
|
61
|
+
return {
|
|
62
|
+
block: true,
|
|
63
|
+
reason:
|
|
64
|
+
result.decision === "deny"
|
|
65
|
+
? `Denied by permission rules${ruleNote}.`
|
|
66
|
+
: "This action needs interactive approval, which is not available inside a workflow agent. Ask for it to be added to the allow rules, or work around it.",
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|