one-code-extension 0.1.4 → 0.3.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 +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -1,31 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Runs a parsed workflow script body in a worker thread (`script-worker.mjs`),
|
|
3
|
+
* inside a `node:vm` context there. Off the TUI thread, a runaway script
|
|
4
|
+
* cannot freeze pi, and the worker's heartbeat lets the host kill CPU loops
|
|
5
|
+
* even after an await — which a vm `timeout` (synchronous prefix only) never
|
|
6
|
+
* could. The run's signal also stops scripts that await forever.
|
|
3
7
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* injected agent(), not here.
|
|
8
|
+
* Only agent()/workflow() RPCs and log/phase notices reach the host, through
|
|
9
|
+
* the gated globals. The vm context is a determinism guard for resume replay,
|
|
10
|
+
* NOT a security sandbox: a script can reach the worker realm through the
|
|
11
|
+
* prototype chain of its globals, so what it gets is the worker's process, not
|
|
12
|
+
* pi's — the same class of exposure as before, one thread further away.
|
|
10
13
|
*/
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
import type
|
|
14
|
+
import { Worker } from "node:worker_threads";
|
|
15
|
+
import { performance } from "node:perf_hooks";
|
|
16
|
+
import { MAX_AGENTS_PER_RUN, MAX_ITEMS_PER_CALL, MAX_NOTICES_PER_TICK, type ScriptGlobals } from "./globals.ts";
|
|
17
|
+
import { whenAborted } from "../lib/abort.ts";
|
|
14
18
|
import { wrapScriptBody } from "./script-source.ts";
|
|
15
|
-
import { WorkflowScriptError } from "./types.ts";
|
|
19
|
+
import { WorkflowScriptError, type AgentCallOptions } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export interface WorkflowRuntimeOptions {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
/** Maximum time without a worker heartbeat, not a timeout on agent I/O. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SLOT_SPENT = 0;
|
|
28
|
+
const SLOT_HEARTBEAT = 1;
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
type WorkerMessage =
|
|
31
|
+
| { type: "result"; value: unknown }
|
|
32
|
+
| { type: "error"; message: string }
|
|
33
|
+
| { type: "log" | "phase"; value: string }
|
|
34
|
+
| { type: "call"; id: number; method: "agent"; args: [string, AgentCallOptions?] }
|
|
35
|
+
| { type: "call"; id: number; method: "workflow"; args: [unknown, unknown?] };
|
|
18
36
|
|
|
19
|
-
export async function runWorkflowScript(
|
|
20
|
-
|
|
21
|
-
|
|
37
|
+
export async function runWorkflowScript(
|
|
38
|
+
body: string,
|
|
39
|
+
globals: ScriptGlobals,
|
|
40
|
+
filename = "workflow.js",
|
|
41
|
+
options: WorkflowRuntimeOptions = {},
|
|
42
|
+
): Promise<unknown> {
|
|
43
|
+
if (options.signal?.aborted) throw new WorkflowScriptError("Workflow run was aborted");
|
|
44
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
45
|
+
// Two atomic slots shared with the worker: [0] the output tokens spent so
|
|
46
|
+
// far (kept current here so budget.spent()/remaining() stay synchronous in
|
|
47
|
+
// scripts), [1] the worker's heartbeat counter (bumped by its own timer, so
|
|
48
|
+
// a script that starves the worker's event loop stops bumping it). Atomics,
|
|
49
|
+
// not messages: the host reads them on its own schedule, and a stalled host
|
|
50
|
+
// loop cannot misread a healthy worker.
|
|
51
|
+
const shared = new BigInt64Array(new SharedArrayBuffer(2 * BigInt64Array.BYTES_PER_ELEMENT));
|
|
52
|
+
const updateBudget = () => Atomics.store(shared, SLOT_SPENT, BigInt(Math.trunc(globals.budget.spent())));
|
|
53
|
+
updateBudget();
|
|
54
|
+
const worker = new Worker(new URL("./script-worker.mjs", import.meta.url), {
|
|
55
|
+
workerData: {
|
|
56
|
+
source: wrapScriptBody(body), filename, args: globals.args,
|
|
57
|
+
total: globals.budget.total, shared: shared.buffer,
|
|
58
|
+
maxItems: MAX_ITEMS_PER_CALL, maxPending: MAX_AGENTS_PER_RUN, maxNotices: MAX_NOTICES_PER_TICK,
|
|
59
|
+
},
|
|
60
|
+
// Do not inherit CLI test/load hooks into this plain JavaScript worker.
|
|
61
|
+
execArgv: [],
|
|
62
|
+
});
|
|
63
|
+
let watchdog: ReturnType<typeof setInterval> | undefined;
|
|
64
|
+
let unhookAbort = () => {};
|
|
22
65
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
66
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let lastHeartbeat = performance.now();
|
|
69
|
+
let lastBeat = 0n;
|
|
70
|
+
const fail = (message: string) => {
|
|
71
|
+
if (stopped) return;
|
|
72
|
+
stopped = true;
|
|
73
|
+
reject(new WorkflowScriptError(message));
|
|
74
|
+
};
|
|
75
|
+
unhookAbort = whenAborted(options.signal, () => fail("Workflow run was aborted"));
|
|
76
|
+
watchdog = setInterval(() => {
|
|
77
|
+
updateBudget();
|
|
78
|
+
const beat = Atomics.load(shared, SLOT_HEARTBEAT);
|
|
79
|
+
if (beat !== lastBeat) {
|
|
80
|
+
lastBeat = beat;
|
|
81
|
+
lastHeartbeat = performance.now();
|
|
82
|
+
}
|
|
83
|
+
// Until the first beat the worker is still starting up: allow at least 5 s.
|
|
84
|
+
const allowed = lastBeat === 0n ? Math.max(5_000, timeoutMs) : timeoutMs;
|
|
85
|
+
if (performance.now() - lastHeartbeat > allowed) {
|
|
86
|
+
fail(`Workflow script timed out: worker was unresponsive for ${timeoutMs}ms`);
|
|
87
|
+
}
|
|
88
|
+
}, Math.max(10, Math.min(250, timeoutMs / 4)));
|
|
89
|
+
const reply = async (message: Extract<WorkerMessage, { type: "call" }>) => {
|
|
90
|
+
try {
|
|
91
|
+
const value = await (message.method === "agent"
|
|
92
|
+
? globals.agent(...message.args)
|
|
93
|
+
: globals.workflow(...message.args));
|
|
94
|
+
if (stopped) return;
|
|
95
|
+
updateBudget();
|
|
96
|
+
worker.postMessage({ id: message.id, value });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (stopped) return;
|
|
99
|
+
worker.postMessage({ id: message.id, error: {
|
|
100
|
+
message: error instanceof Error ? error.message : String(error),
|
|
101
|
+
fatal: error instanceof WorkflowScriptError,
|
|
102
|
+
} });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
worker.on("message", (message: WorkerMessage) => {
|
|
106
|
+
if (stopped) return;
|
|
107
|
+
try {
|
|
108
|
+
switch (message.type) {
|
|
109
|
+
case "result": stopped = true; resolve(message.value); break;
|
|
110
|
+
case "error": fail(`Workflow script failed: ${message.message}`); break;
|
|
111
|
+
case "log": globals.log(message.value); break;
|
|
112
|
+
case "phase": globals.phase(message.value); break;
|
|
113
|
+
case "call": void reply(message).catch((error) => fail(String(error))); break;
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
worker.on("error", (error) => fail(`Workflow worker failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
120
|
+
worker.on("exit", (code) => fail(`Workflow worker exited before returning a result (code ${code})`));
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
clearInterval(watchdog);
|
|
124
|
+
unhookAbort();
|
|
125
|
+
await worker.terminate();
|
|
27
126
|
}
|
|
28
|
-
// The wrap's final statement is the async IIFE, so the completion value is
|
|
29
|
-
// the promise of the script's own result (including a top-level `return`).
|
|
30
|
-
return await Promise.resolve(completion);
|
|
31
127
|
}
|
|
@@ -1,18 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Persistent workflow status strip below the editor (Claude Code's bottom
|
|
3
|
+
* workflow entry): one row per run — marker, name, description, right-aligned
|
|
4
|
+
* live stats — rendered via the component overload of ctx.ui.setWidget so it
|
|
5
|
+
* can right-align against the real width. Re-renders are debounced against
|
|
6
|
+
* chatty fan-outs; a 1s ticker keeps elapsed time moving while a run is live.
|
|
7
|
+
*
|
|
8
|
+
* The strip also carries the soft-focus state for the down-arrow flow (wired
|
|
9
|
+
* in index.ts): `setFocus` highlights a row, and the factory captures the TUI
|
|
10
|
+
* handle + the focused editor component so the input hook can verify the
|
|
11
|
+
* editor really has focus before stealing keys from it.
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
14
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { linesComponent, liveUiCtx, safeThemeBold, safeThemePaint } from "../lib/tui-render.ts";
|
|
8
16
|
import type { RunHandle, WorkflowRunManager } from "./run-manager.ts";
|
|
17
|
+
import { MAX_STATUS_ROWS, renderStatusRows, type ViewerRunSnapshot } from "./viewer.ts";
|
|
9
18
|
|
|
10
19
|
const WIDGET_KEY = "workflow";
|
|
11
20
|
const DEBOUNCE_MS = 250;
|
|
12
|
-
|
|
21
|
+
|
|
22
|
+
/** The slice of pi-tui's TUI the strip needs (structurally typed, no dep). */
|
|
23
|
+
interface TuiLike {
|
|
24
|
+
getFocusedComponent?(): unknown;
|
|
25
|
+
}
|
|
13
26
|
|
|
14
27
|
export class WorkflowWidget {
|
|
15
28
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
29
|
+
private ticker: ReturnType<typeof setInterval> | undefined;
|
|
30
|
+
/** Soft-focused row index (into the newest-first snapshot list). */
|
|
31
|
+
focusIndex: number | undefined;
|
|
32
|
+
/** Captured from the widget factory; used by the focus wiring in index.ts. */
|
|
33
|
+
tui: TuiLike | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The core editor component, captured while it holds focus (duck-typed on
|
|
36
|
+
* getText — dialogs and selectors don't have it). Identity-compared before
|
|
37
|
+
* consuming any key, so the strip never steals input from a dialog or a
|
|
38
|
+
* pop-up editor.
|
|
39
|
+
*/
|
|
40
|
+
editorBaseline: unknown;
|
|
16
41
|
|
|
17
42
|
constructor(
|
|
18
43
|
private readonly manager: WorkflowRunManager,
|
|
@@ -25,8 +50,62 @@ export class WorkflowWidget {
|
|
|
25
50
|
this.schedule();
|
|
26
51
|
}
|
|
27
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Re-set the widget so it re-inserts below rows other extensions just set
|
|
55
|
+
* (setWidget order is last-write order); the permission-mode badge triggers
|
|
56
|
+
* this over the status channel to keep the strip beneath the mode line.
|
|
57
|
+
*/
|
|
58
|
+
refresh(): void {
|
|
59
|
+
this.schedule();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Rows currently selectable (capped like the render). */
|
|
63
|
+
rowCount(): number {
|
|
64
|
+
return Math.min(this.manager.list().length, MAX_STATUS_ROWS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
selectedRun(): ViewerRunSnapshot | undefined {
|
|
68
|
+
if (this.focusIndex === undefined) return undefined;
|
|
69
|
+
return this.manager.snapshots(MAX_STATUS_ROWS)[this.focusIndex];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setFocus(index: number | undefined): void {
|
|
73
|
+
this.focusIndex = index;
|
|
74
|
+
this.render();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the core editor is focused and empty — safe to take the down key. */
|
|
78
|
+
editorFocusedAndIdle(ctx: ExtensionContext): boolean {
|
|
79
|
+
if (!this.editorFocused()) return false;
|
|
80
|
+
try {
|
|
81
|
+
return ctx.ui.getEditorText().trim() === "";
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True when the core editor still holds real focus (strip may keep soft focus). */
|
|
88
|
+
editorFocused(): boolean {
|
|
89
|
+
return Boolean(this.tui && this.editorBaseline && this.tui.getFocusedComponent?.() === this.editorBaseline);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Set by dispose(): pi replaces the session (and this instance) on /clear,
|
|
94
|
+
* /new and /resume and invalidates the old ctx, while aborting runs still
|
|
95
|
+
* report progress for a moment — schedule()/render() must go quiet the
|
|
96
|
+
* instant we are disposed (same hazard as the subagent panel, SUBAGENT-REVIEW H1).
|
|
97
|
+
*/
|
|
98
|
+
private disposed = false;
|
|
99
|
+
|
|
100
|
+
dispose(): void {
|
|
101
|
+
this.disposed = true;
|
|
102
|
+
if (this.timer) clearTimeout(this.timer);
|
|
103
|
+
if (this.ticker) clearInterval(this.ticker);
|
|
104
|
+
this.timer = this.ticker = undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
28
107
|
private schedule(): void {
|
|
29
|
-
if (this.timer) return;
|
|
108
|
+
if (this.disposed || this.timer) return;
|
|
30
109
|
this.timer = setTimeout(() => {
|
|
31
110
|
this.timer = undefined;
|
|
32
111
|
this.render();
|
|
@@ -35,22 +114,51 @@ export class WorkflowWidget {
|
|
|
35
114
|
}
|
|
36
115
|
|
|
37
116
|
private render(): void {
|
|
38
|
-
const ctx = this.getCtx();
|
|
39
|
-
if (!ctx
|
|
40
|
-
|
|
41
|
-
|
|
117
|
+
const ctx = this.disposed ? undefined : liveUiCtx(this.getCtx());
|
|
118
|
+
if (!ctx) {
|
|
119
|
+
this.syncTicker([]);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const totalRuns = this.manager.list().length;
|
|
123
|
+
const runs = this.manager.snapshots(MAX_STATUS_ROWS);
|
|
124
|
+
this.syncTicker(runs);
|
|
125
|
+
if (!runs.length) {
|
|
42
126
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
43
127
|
return;
|
|
44
128
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
129
|
+
if (this.focusIndex !== undefined && this.focusIndex >= this.rowCount()) {
|
|
130
|
+
this.focusIndex = Math.max(0, this.rowCount() - 1);
|
|
131
|
+
}
|
|
132
|
+
const selected = this.focusIndex;
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
ctx.ui.setWidget(
|
|
135
|
+
WIDGET_KEY,
|
|
136
|
+
(tui, theme) => {
|
|
137
|
+
this.tui = tui as TuiLike;
|
|
138
|
+
const focused = (tui as TuiLike).getFocusedComponent?.();
|
|
139
|
+
if (focused && typeof (focused as { getText?: unknown }).getText === "function") {
|
|
140
|
+
this.editorBaseline = focused;
|
|
141
|
+
}
|
|
142
|
+
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
143
|
+
// One-space indent to align with string-array widgets (pi's Text
|
|
144
|
+
// wraps those with paddingX=1 — the permission-mode badge above).
|
|
145
|
+
return linesComponent((width) =>
|
|
146
|
+
renderStatusRows({ runs, totalRuns, selected, width: width - 1, now }, paint).map((line) => ` ${line}`),
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
{ placement: "belowEditor" },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Keep elapsed time ticking while any run is live; stop when none is. */
|
|
154
|
+
private syncTicker(runs: ViewerRunSnapshot[]): void {
|
|
155
|
+
const active = runs.some((run) => run.status === "running");
|
|
156
|
+
if (active && !this.ticker && !this.disposed) {
|
|
157
|
+
this.ticker = setInterval(() => this.render(), 1000);
|
|
158
|
+
this.ticker.unref?.();
|
|
159
|
+
} else if (!active && this.ticker) {
|
|
160
|
+
clearInterval(this.ticker);
|
|
161
|
+
this.ticker = undefined;
|
|
52
162
|
}
|
|
53
|
-
lines.push(" /workflows to inspect · /workflows stop <runId> to stop");
|
|
54
|
-
ctx.ui.setWidget(WIDGET_KEY, lines);
|
|
55
163
|
}
|
|
56
164
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree-session bash guards (pure).
|
|
3
|
+
*
|
|
4
|
+
* While enter_worktree isolation is active, other sessions may be working in
|
|
5
|
+
* the shared checkout and in sibling worktrees at the same time. Two
|
|
6
|
+
* invariants are enforced on bash commands BEFORE execution (and before the
|
|
7
|
+
* permission prompt — the guard runs in the same tool_call hook that rewrites
|
|
8
|
+
* inputs, which loads ahead of the permission gate):
|
|
9
|
+
*
|
|
10
|
+
* 1. Git operations must verifiably target this session's own worktree. A git
|
|
11
|
+
* whose repository is decided at runtime (`xargs`/`parallel`/`find -exec`
|
|
12
|
+
* feeding it arguments, a `-C "$DIR"` computed from a variable, a `cd`
|
|
13
|
+
* whose destination cannot be resolved), or that points into the shared
|
|
14
|
+
* checkout or a sibling worktree (`git -C`, `--git-dir`, `--work-tree`, a
|
|
15
|
+
* preceding `cd`), is refused with the fix named. Git against an unrelated
|
|
16
|
+
* repository elsewhere on disk is left alone — the permission gate and
|
|
17
|
+
* auto-mode still apply to it.
|
|
18
|
+
* 2. The stash stack is shared by the main checkout and every worktree of the
|
|
19
|
+
* repository, and parallel sessions can interleave on it. Stash forms that
|
|
20
|
+
* collide with a concurrent session (untagged push, `pop`, `clear`,
|
|
21
|
+
* ref-less `drop`) are refused with the tag + apply-by-SHA recipe.
|
|
22
|
+
*
|
|
23
|
+
* Steering, not security: only bash is inspected here, and an unparseable
|
|
24
|
+
* command is refused only when it visibly involves git (fail closed on the
|
|
25
|
+
* invariant, fail open on everything else).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { isWithin, toAbsolute } from "../auto-mode/paths.ts";
|
|
30
|
+
import { gitSubcommand, leadTokens, parseCommand, resolvePayload, type Token } from "../auto-mode/shell-analysis.ts";
|
|
31
|
+
|
|
32
|
+
export interface WorktreeGuardContext {
|
|
33
|
+
command: string;
|
|
34
|
+
/** The isolated worktree this session works in. */
|
|
35
|
+
worktreePath: string;
|
|
36
|
+
/** Root of the shared checkout the worktree belongs to. */
|
|
37
|
+
sharedRoot: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Expansion syntax the guard cannot resolve statically. */
|
|
41
|
+
const hasExpansion = (value: string) => /[$`]/.test(value);
|
|
42
|
+
|
|
43
|
+
const isolated = (worktreePath: string, problem: string, fix: string): string =>
|
|
44
|
+
`This session is isolated in the worktree ${worktreePath}, but ${problem}. ` +
|
|
45
|
+
`Refusing to run it — a worktree-isolated session's git operations must target its own worktree. ${fix}`;
|
|
46
|
+
|
|
47
|
+
function stashMessage(form: string, hazard: string): string {
|
|
48
|
+
return (
|
|
49
|
+
`Blocked: \`${form}\` — the git stash stack is shared between the main checkout and every worktree of this repository, and parallel sessions may push or pop entries concurrently; ${hazard}. ` +
|
|
50
|
+
"Prefer a temporary WIP commit on this worktree's branch to set work aside. If you must stash: " +
|
|
51
|
+
'`git stash push -u -m "<unique-tag>"`, capture your entry\'s SHA with `git stash list --format=\'%H %gs\'`, ' +
|
|
52
|
+
"restore with `git stash apply <sha>` (not pop), and drop your entry only after re-finding it by tag."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stashReason(rest: Token[]): string | undefined {
|
|
57
|
+
const words = rest.map((t) => t.value);
|
|
58
|
+
const sub = words.find((w) => !w.startsWith("-"));
|
|
59
|
+
// `-m` may ride in a short-flag bundle (`git stash push -um wip`).
|
|
60
|
+
const tagged = words.some(
|
|
61
|
+
(w) => (/^-[a-zA-Z]+$/.test(w) && w.includes("m")) || w === "--message" || w.startsWith("--message="),
|
|
62
|
+
);
|
|
63
|
+
if (sub === undefined || sub === "push" || sub === "save") {
|
|
64
|
+
if (tagged) return undefined;
|
|
65
|
+
return stashMessage(
|
|
66
|
+
sub ? `git stash ${sub}` : "git stash",
|
|
67
|
+
"an untagged entry cannot be reliably re-found once other sessions push their own",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (sub === "pop") {
|
|
71
|
+
return stashMessage("git stash pop", "`pop` resolves stash indexes at apply time, so it can grab or destroy another session's entry");
|
|
72
|
+
}
|
|
73
|
+
if (sub === "clear") {
|
|
74
|
+
return stashMessage("git stash clear", "`clear` deletes every session's stashes, not just yours");
|
|
75
|
+
}
|
|
76
|
+
if (sub === "drop") {
|
|
77
|
+
const ref = words.slice(words.indexOf("drop") + 1).find((w) => !w.startsWith("-"));
|
|
78
|
+
if (!ref) {
|
|
79
|
+
return stashMessage("git stash drop", "a ref-less `drop` deletes whatever is currently stash@{0}, which may be another session's entry");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function worktreeBashGuardReason({ command, worktreePath, sharedRoot }: WorktreeGuardContext): string | undefined {
|
|
86
|
+
const { segments, parseFailed } = parseCommand(command);
|
|
87
|
+
if (parseFailed || segments.length === 0) {
|
|
88
|
+
if (!/\bgit\b/.test(command)) return undefined;
|
|
89
|
+
return isolated(
|
|
90
|
+
worktreePath,
|
|
91
|
+
"this command is too complex to verify that its git operations stay inside the worktree",
|
|
92
|
+
`Break it into plain, separate git commands with literal paths and run them from ${worktreePath}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Claude Code's exact refusal for stdin-fed git (one static string for
|
|
97
|
+
// both mechanisms) — keep it byte-identical to the capture.
|
|
98
|
+
const stdinFed = () =>
|
|
99
|
+
isolated(
|
|
100
|
+
worktreePath,
|
|
101
|
+
"this command feeds git its arguments from stdin at runtime (xargs/parallel), so the repository it targets cannot be verified",
|
|
102
|
+
`Run the equivalent from ${worktreePath} without the redirect.`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** Directory later segments run in; undefined = not statically known. */
|
|
106
|
+
let dir: string | undefined = worktreePath;
|
|
107
|
+
/** Subshell nesting: a `cd` inside `(...)` must not leak past the `)`. */
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let dirBeforeSubshell: { dir: string | undefined } | undefined;
|
|
110
|
+
|
|
111
|
+
const checkSegment = (seg: (typeof segments)[number]): string | undefined => {
|
|
112
|
+
const tokens = leadTokens(seg);
|
|
113
|
+
const rawLead = tokens[0]?.value;
|
|
114
|
+
if (!rawLead) return undefined;
|
|
115
|
+
|
|
116
|
+
if (rawLead === "cd") {
|
|
117
|
+
// Skip cd's own options (-P/-L/-e/-@); `--` ends them; a lone `-`
|
|
118
|
+
// means the previous directory, which is not statically known.
|
|
119
|
+
let j = 1;
|
|
120
|
+
while (j < tokens.length && tokens[j].value.startsWith("-") && !["-", "--"].includes(tokens[j].value)) j++;
|
|
121
|
+
if (tokens[j]?.value === "--") j++;
|
|
122
|
+
const target = tokens[j]?.value;
|
|
123
|
+
if (!target) dir = homedir();
|
|
124
|
+
else if (hasExpansion(target) || target === "-") dir = undefined;
|
|
125
|
+
// An absolute (or ~) destination re-anchors the tracked directory
|
|
126
|
+
// even when it was unknown — later git commands become checkable again.
|
|
127
|
+
else if (target.startsWith("/") || target === "~" || target.startsWith("~/")) dir = toAbsolute("/", target, homedir());
|
|
128
|
+
else if (dir !== undefined) dir = toAbsolute(dir, target, homedir());
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (rawLead === "pushd" || rawLead === "popd") {
|
|
132
|
+
dir = undefined;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { command: cmd, args, peeled } = resolvePayload(tokens);
|
|
137
|
+
|
|
138
|
+
// Git whose arguments are assembled at runtime — the repository it will
|
|
139
|
+
// target cannot be read off the command.
|
|
140
|
+
if (cmd === "git" && peeled.includes("xargs")) return stdinFed();
|
|
141
|
+
if (rawLead === "parallel" && tokens.some((t) => t.value === "git")) return stdinFed();
|
|
142
|
+
if (
|
|
143
|
+
cmd === "find" &&
|
|
144
|
+
args.some((a) => ["-exec", "-execdir", "-ok", "-okdir"].includes(a.value)) &&
|
|
145
|
+
args.some((a) => a.value === "git")
|
|
146
|
+
) {
|
|
147
|
+
return isolated(
|
|
148
|
+
worktreePath,
|
|
149
|
+
"this command assembles git invocations at runtime (find -exec), so the repositories they target cannot be verified",
|
|
150
|
+
"Run the git commands directly, with explicit literal paths, instead of assembling them at runtime.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (cmd !== "git") return undefined;
|
|
155
|
+
|
|
156
|
+
if (dir === undefined) {
|
|
157
|
+
return isolated(
|
|
158
|
+
worktreePath,
|
|
159
|
+
"a preceding directory change makes the repository this git command targets unverifiable",
|
|
160
|
+
`Use literal paths inside ${worktreePath} instead.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Effective repository target: the tracked cwd, adjusted by global flags.
|
|
165
|
+
let effective = dir;
|
|
166
|
+
const extraTargets: string[] = [];
|
|
167
|
+
let i = 0;
|
|
168
|
+
while (i < args.length) {
|
|
169
|
+
const value = args[i].value;
|
|
170
|
+
if (!value.startsWith("-")) break;
|
|
171
|
+
const pathFlag = value === "-C" || value === "--git-dir" || value === "--work-tree";
|
|
172
|
+
const inlined = value.startsWith("--git-dir=") || value.startsWith("--work-tree=");
|
|
173
|
+
if (pathFlag || inlined) {
|
|
174
|
+
const raw = inlined ? value.slice(value.indexOf("=") + 1) : args[i + 1]?.value;
|
|
175
|
+
if (!raw || hasExpansion(raw)) {
|
|
176
|
+
return isolated(
|
|
177
|
+
worktreePath,
|
|
178
|
+
`\`git ${value}\` computes its repository target at runtime, so it cannot be verified`,
|
|
179
|
+
`Use literal paths inside ${worktreePath} instead.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const resolved = toAbsolute(effective, raw, homedir());
|
|
183
|
+
if (value === "-C") effective = resolved;
|
|
184
|
+
else extraTargets.push(resolved);
|
|
185
|
+
i += inlined ? 1 : 2;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
i += ["-c", "--namespace", "--exec-path", "--config-env"].includes(value) ? 2 : 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const targets = [effective, ...extraTargets];
|
|
192
|
+
for (const target of targets) {
|
|
193
|
+
if (isWithin(worktreePath, target)) continue;
|
|
194
|
+
if (isWithin(sharedRoot, target)) {
|
|
195
|
+
return isolated(
|
|
196
|
+
worktreePath,
|
|
197
|
+
`this git command targets ${target}, which is the shared checkout or another worktree of the same repository`,
|
|
198
|
+
`Run the equivalent against ${worktreePath}.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// An unrelated repository elsewhere on disk — not this guard's concern.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The stash stack is per-repository and shared across its worktrees. Any
|
|
205
|
+
// shared-root target that is NOT inside the worktree already returned
|
|
206
|
+
// above, so only worktree-contained targets can reach this check.
|
|
207
|
+
if (targets.some((t) => isWithin(worktreePath, t))) {
|
|
208
|
+
const { sub, rest } = gitSubcommand(args);
|
|
209
|
+
if (sub === "stash") {
|
|
210
|
+
const reason = stashReason(rest);
|
|
211
|
+
if (reason) return reason;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const seg of segments) {
|
|
218
|
+
// Subshell bookkeeping reads the RAW tokens — leadTokens strips the very
|
|
219
|
+
// parens being counted. A `cd` between `(` and `)` is scoped: the tracked
|
|
220
|
+
// directory is restored when the subshell closes.
|
|
221
|
+
const first = seg.tokens[0]?.value ?? "";
|
|
222
|
+
const opens = (/^\(+/.exec(first)?.[0] ?? "").length;
|
|
223
|
+
if (opens > 0 && depth === 0) dirBeforeSubshell = { dir };
|
|
224
|
+
depth += opens;
|
|
225
|
+
|
|
226
|
+
const reason = checkSegment(seg);
|
|
227
|
+
if (reason) return reason;
|
|
228
|
+
|
|
229
|
+
const last = seg.tokens[seg.tokens.length - 1]?.value ?? "";
|
|
230
|
+
const closes = (/\)+$/.exec(last)?.[0] ?? "").length;
|
|
231
|
+
if (closes > 0 && depth > 0) {
|
|
232
|
+
depth = Math.max(0, depth - closes);
|
|
233
|
+
if (depth === 0 && dirBeforeSubshell) {
|
|
234
|
+
dir = dirBeforeSubshell.dir;
|
|
235
|
+
dirBeforeSubshell = undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
@@ -20,6 +20,8 @@ import { Type } from "typebox";
|
|
|
20
20
|
import { restoreLatestDetails } from "../lib/branch-restore.ts";
|
|
21
21
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
22
22
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
23
|
+
import { worktreeBashGuardReason } from "./guards.ts";
|
|
24
|
+
import { ORIGINAL_COMMAND_CHANNEL, type OriginalCommandRecord } from "../lib/original-command.ts";
|
|
23
25
|
import { rewriteToolInput, validateWorktreeName } from "./rewrite.ts";
|
|
24
26
|
import { ccToolRenderers } from "../lib/tui-render.ts";
|
|
25
27
|
|
|
@@ -33,6 +35,8 @@ interface WorktreeState {
|
|
|
33
35
|
baseCommit?: string;
|
|
34
36
|
createdByUs: boolean;
|
|
35
37
|
originalCwd: string;
|
|
38
|
+
/** Root of the shared checkout (`git rev-parse --show-toplevel` at entry). */
|
|
39
|
+
sharedRoot: string;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
interface WorktreeDetails {
|
|
@@ -64,7 +68,12 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
64
68
|
const applyState = (next: WorktreeState | undefined) => {
|
|
65
69
|
state = next;
|
|
66
70
|
if (next) {
|
|
67
|
-
pi.events.emit(REMINDER_CHANNEL, {
|
|
71
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
72
|
+
text: reminderFor(next),
|
|
73
|
+
scope: "every-turn",
|
|
74
|
+
key: REMINDER_KEY,
|
|
75
|
+
placement: "sticky-append",
|
|
76
|
+
});
|
|
68
77
|
} else {
|
|
69
78
|
pi.events.emit(REMINDER_CHANNEL, { key: REMINDER_KEY, remove: true });
|
|
70
79
|
}
|
|
@@ -74,6 +83,8 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
74
83
|
const details = restoreLatestDetails<WorktreeDetails>(ctx.sessionManager.getBranch(), WORKTREE_TOOLS, (d) => d?.worktreeState !== undefined);
|
|
75
84
|
let restored = details?.worktreeState ?? undefined;
|
|
76
85
|
if (restored && !existsSync(restored.path)) restored = undefined;
|
|
86
|
+
// Sessions persisted before sharedRoot existed: originalCwd is the best guess.
|
|
87
|
+
if (restored && !restored.sharedRoot) restored = { ...restored, sharedRoot: restored.originalCwd };
|
|
77
88
|
applyState(restored);
|
|
78
89
|
};
|
|
79
90
|
|
|
@@ -82,8 +93,22 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
82
93
|
|
|
83
94
|
pi.on("tool_call", (event) => {
|
|
84
95
|
if (!state) return;
|
|
85
|
-
if (["enter_worktree", "exit_worktree", "
|
|
86
|
-
|
|
96
|
+
if (["enter_worktree", "exit_worktree", "Agent", "SendMessage", "workflow"].includes(event.toolName)) return;
|
|
97
|
+
if (event.toolName === "bash") {
|
|
98
|
+
// Guard before rewriting (and before the permission prompt — this
|
|
99
|
+
// extension loads ahead of permissions): git must verifiably target
|
|
100
|
+
// this worktree, and shared-stash footguns are refused with the recipe.
|
|
101
|
+
const command = (event.input as Record<string, unknown>).command;
|
|
102
|
+
if (typeof command === "string") {
|
|
103
|
+
const reason = worktreeBashGuardReason({ command, worktreePath: state.path, sharedRoot: state.sharedRoot });
|
|
104
|
+
if (reason) return { block: true, reason };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const { originalCommand } = rewriteToolInput(event.toolName, event.input as Record<string, unknown>, state.path);
|
|
108
|
+
if (originalCommand !== undefined) {
|
|
109
|
+
const record: OriginalCommandRecord = { toolCallId: event.toolCallId, command: originalCommand, cwd: state.path };
|
|
110
|
+
pi.events.emit(ORIGINAL_COMMAND_CHANNEL, record);
|
|
111
|
+
}
|
|
87
112
|
});
|
|
88
113
|
|
|
89
114
|
pi.registerTool({
|
|
@@ -120,7 +145,7 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
120
145
|
const known = await listWorktreePaths(ctx.cwd);
|
|
121
146
|
const target = known.find((p) => p === params.path);
|
|
122
147
|
if (!target) return fail(`${params.path} is not a worktree of this repository. Known worktrees:\n${known.join("\n")}`);
|
|
123
|
-
const next: WorktreeState = { path: target, createdByUs: false, originalCwd: ctx.cwd };
|
|
148
|
+
const next: WorktreeState = { path: target, createdByUs: false, originalCwd: ctx.cwd, sharedRoot: repoRoot };
|
|
124
149
|
applyState(next);
|
|
125
150
|
return {
|
|
126
151
|
content: [{ type: "text", text: `Switched into existing worktree ${target}. All work now happens there; exit_worktree returns to ${ctx.cwd}.` }],
|
|
@@ -157,7 +182,7 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
157
182
|
return fail(`Could not create worktree: ${(error as Error).message}`);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
const next: WorktreeState = { path, branch, baseCommit, createdByUs: true, originalCwd: ctx.cwd };
|
|
185
|
+
const next: WorktreeState = { path, branch, baseCommit, createdByUs: true, originalCwd: ctx.cwd, sharedRoot: repoRoot };
|
|
161
186
|
applyState(next);
|
|
162
187
|
return {
|
|
163
188
|
content: [
|