one-code-extension 0.3.0 → 0.4.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/README.md +189 -94
- package/docs/guide/README.md +3 -2
- package/docs/guide/bring-your-claude-code-setup.md +7 -5
- package/docs/guide/configuration.md +7 -5
- package/docs/guide/differences-from-claude-code.md +25 -14
- package/docs/guide/doctor.md +5 -3
- package/docs/guide/hooks.md +20 -6
- package/docs/guide/installation.md +62 -12
- package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
- package/docs/guide/providers-and-models.md +12 -10
- package/docs/guide/reference.md +9 -4
- package/docs/guide/sessions-and-context.md +23 -3
- package/docs/guide/skills-plugins-and-mcp.md +7 -5
- package/docs/guide/subagents-and-workflows.md +13 -11
- package/docs/guide/tasks-and-background-work.md +10 -8
- package/docs/guide/terminal-interface.md +7 -5
- package/docs/guide/tools.md +29 -5
- package/docs/guide/troubleshooting.md +26 -14
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* and every caller treats that as "not provably contained".
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { lstatSync, readlinkSync
|
|
18
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
19
19
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
-
import { expandTilde } from "../lib/paths.ts";
|
|
20
|
+
import { comparablePath, expandTilde, gitBashPathToNative, isPathAtOrUnder, tryRealpath } from "../lib/paths.ts";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Tools whose calls write to a path. Lives here — the lowest shared layer both
|
|
@@ -41,23 +41,8 @@ export function pathArgument(input: Record<string, unknown> | undefined): string
|
|
|
41
41
|
return typeof path === "string" ? path : undefined;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
const normalized = resolve(target).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
47
|
-
return process.platform === "linux" ? normalized : normalized.toLowerCase();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function tryRealpath(target: string): string | undefined {
|
|
51
|
-
try {
|
|
52
|
-
return realpathSync.native(target);
|
|
53
|
-
} catch {
|
|
54
|
-
try {
|
|
55
|
-
return realpathSync(target);
|
|
56
|
-
} catch {
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
44
|
+
/** The shared comparison form (lib/paths.ts): `/` separators, case-folded on darwin/win32. */
|
|
45
|
+
const normalize = comparablePath;
|
|
61
46
|
|
|
62
47
|
/**
|
|
63
48
|
* Resolve a path for containment checks, following a symlink leaf that does not
|
|
@@ -99,11 +84,9 @@ export function resolveForContainment(target: string): string | undefined {
|
|
|
99
84
|
}
|
|
100
85
|
}
|
|
101
86
|
|
|
102
|
-
/** True only when `target` is provably at or under `base
|
|
87
|
+
/** True only when `target` is provably at or under `base` (lib/paths.ts `isPathAtOrUnder`, argument order kept for the callers here). */
|
|
103
88
|
export function isWithin(base: string, target: string): boolean {
|
|
104
|
-
|
|
105
|
-
const normalizedTarget = normalize(target);
|
|
106
|
-
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`);
|
|
89
|
+
return isPathAtOrUnder(target, base);
|
|
107
90
|
}
|
|
108
91
|
|
|
109
92
|
/**
|
|
@@ -119,3 +102,18 @@ export function toAbsolute(cwd: string, token: string, home: string): string {
|
|
|
119
102
|
if (isAbsolute(token)) return resolve(token);
|
|
120
103
|
return resolve(cwd, token);
|
|
121
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* {@link toAbsolute} for a token taken from a bash command line. On Windows the
|
|
108
|
+
* bash tool runs Git Bash, whose absolute paths are MSYS spellings — `/c/proj/f`
|
|
109
|
+
* is `C:\proj\f`, `/tmp/f` sits under the user's temp dir — so the token is
|
|
110
|
+
* converted first (lib/paths.ts gitBashPathToNative). `path.resolve` alone reads
|
|
111
|
+
* `/c/proj/f` as `<cwd drive>:\c\proj\f`, which no gate recognises as the
|
|
112
|
+
* project: the error fell towards escalate/prompt, never towards allow, but every
|
|
113
|
+
* in-project write spelled that way went to the classifier. File-tool paths keep
|
|
114
|
+
* `toAbsolute`: pi's read/edit/write resolve them natively, and a gate must
|
|
115
|
+
* judge the path the tool acts on, not the one Git Bash would reach.
|
|
116
|
+
*/
|
|
117
|
+
export function toAbsoluteBash(cwd: string, token: string, home: string): string {
|
|
118
|
+
return toAbsolute(cwd, gitBashPathToNative(token), home);
|
|
119
|
+
}
|
|
@@ -25,14 +25,11 @@
|
|
|
25
25
|
import { analyzeShellCommand } from "./shell-analysis.ts";
|
|
26
26
|
import { autoModeSettingsPaths } from "./config.ts";
|
|
27
27
|
import { oneCodeProjectSettingsPath } from "../lib/one-code-settings.ts";
|
|
28
|
-
import { claudeJsonPath } from "../lib/paths.ts";
|
|
28
|
+
import { claudeJsonPath, comparablePath } from "../lib/paths.ts";
|
|
29
29
|
import { isWritingTool, resolveForContainment, toAbsolute } from "./paths.ts";
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
const forward = path.replace(/\\/g, "/");
|
|
34
|
-
return process.platform === "linux" ? forward : forward.toLowerCase();
|
|
35
|
-
}
|
|
31
|
+
/** The same comparison form resolveForContainment's output is in (lib/paths.ts). */
|
|
32
|
+
const fold = comparablePath;
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
35
|
* Any `.claude/settings.json` or `.claude/settings.local.json`, wherever it
|
|
@@ -127,5 +124,46 @@ export function safetyControlWrite({ toolName, input, cwd, home, oneCodeProjectS
|
|
|
127
124
|
}
|
|
128
125
|
}
|
|
129
126
|
|
|
127
|
+
if (toolName === "powershell") {
|
|
128
|
+
// No PowerShell write model yet, so the floor is textual and wider than
|
|
129
|
+
// bash's: ANY mention of a gate-control file in the command line stops it,
|
|
130
|
+
// whether the cmdlet reads or writes. A `Get-Content` of settings.json
|
|
131
|
+
// costs one prompt; a `Set-Content` that slipped through would cost the
|
|
132
|
+
// gate (the floor may only ever say "stop" — header).
|
|
133
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
134
|
+
if (!command) return undefined;
|
|
135
|
+
for (const token of powershellPathTokens(command, home)) {
|
|
136
|
+
const absolute = toAbsolute(cwd, token, home);
|
|
137
|
+
if (isSafetyControlTarget(resolveForContainment(absolute) ?? absolute, home, perRepoSettings)) return REASON(token);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
return undefined;
|
|
131
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Path-looking tokens of a PowerShell line, quotes stripped, `$env:USERPROFILE`,
|
|
146
|
+
* `$env:HOME`, `$HOME` and `~` expanded to the home dir, backslashes
|
|
147
|
+
* forward-slashed. Anything with a separator or a `.json`-style file name
|
|
148
|
+
* counts; the floor tolerates false positives.
|
|
149
|
+
*/
|
|
150
|
+
export function powershellPathTokens(command: string, home: string): string[] {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const raw of command.split(/[\s;|()]+/)) {
|
|
153
|
+
let token = raw.replace(/^["']+|["',]+$/g, "");
|
|
154
|
+
if (!token) continue;
|
|
155
|
+
token = token
|
|
156
|
+
.replace(/^\$env:(USERPROFILE|HOME)/i, home)
|
|
157
|
+
.replace(/^\$HOME\b/i, home)
|
|
158
|
+
.replace(/^~(?=[\\/]|$)/, home)
|
|
159
|
+
.replace(/\\/g, "/");
|
|
160
|
+
// Parameter names (`-Path`) are not paths; `-Path:value` carries one.
|
|
161
|
+
if (token.startsWith("-")) {
|
|
162
|
+
const colon = token.indexOf(":");
|
|
163
|
+
if (colon === -1) continue;
|
|
164
|
+
token = token.slice(colon + 1);
|
|
165
|
+
}
|
|
166
|
+
if (/[\\/]/.test(token) || /\.json$/i.test(token)) out.push(token);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { isProtectedPath } from "../permissions/protected-paths.ts";
|
|
34
34
|
import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
|
|
35
|
-
import { isWithin, resolveForContainment,
|
|
35
|
+
import { isWithin, resolveForContainment, toAbsoluteBash } from "./paths.ts";
|
|
36
36
|
|
|
37
37
|
export type ShellVerdict = "safe" | "escalate";
|
|
38
38
|
|
|
@@ -43,7 +43,13 @@ export interface ShellEvidence {
|
|
|
43
43
|
/** The commands that actually run, wrappers peeled (`env rm` → `rm`). */
|
|
44
44
|
commands: string[];
|
|
45
45
|
/** Resolved paths this command may write, with containment already decided. */
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Every write target: the token as written, its absolute form as the shell
|
|
48
|
+
* reaches it (`toAbsoluteBash`: Git Bash spellings converted, `~` and cwd
|
|
49
|
+
* applied, not yet realpath'd — the form a "write it here instead" message
|
|
50
|
+
* keeps the model's spelling from), the realpath'd form, and the verdict.
|
|
51
|
+
*/
|
|
52
|
+
writes: { token: string; absolute: string; resolved?: string; outsideCwd: boolean }[];
|
|
47
53
|
/** Credential/secret paths named anywhere in the command (original tokens, never expanded). */
|
|
48
54
|
sensitivePaths: string[];
|
|
49
55
|
/** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
|
|
@@ -722,12 +728,21 @@ export interface AnalyzeInput {
|
|
|
722
728
|
* A redirect landing inside one escalates like a write to `.git/hooks`.
|
|
723
729
|
*/
|
|
724
730
|
protectedDirs?: string[];
|
|
731
|
+
/**
|
|
732
|
+
* REALPATH-resolved directories a read-only command may read from besides
|
|
733
|
+
* the working directory without escalating: the harness's own session dirs
|
|
734
|
+
* (memory, scratchpad, persisted results, this project's transcripts —
|
|
735
|
+
* permissions/matcher.ts `DecideInput.sessionDirPath`), resolved once per
|
|
736
|
+
* session by the caller (compared against realpaths here, unresolved). Reads
|
|
737
|
+
* only; the write and delete checks never consult this list.
|
|
738
|
+
*/
|
|
739
|
+
readableRoots?: string[];
|
|
725
740
|
}
|
|
726
741
|
|
|
727
742
|
/**
|
|
728
743
|
* Classify a shell command. Never denies — see the module contract above.
|
|
729
744
|
*/
|
|
730
|
-
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }: AnalyzeInput): ShellEvidence {
|
|
745
|
+
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [] }: AnalyzeInput): ShellEvidence {
|
|
731
746
|
const evidence: ShellEvidence = {
|
|
732
747
|
verdict: "safe",
|
|
733
748
|
notes: [],
|
|
@@ -790,10 +805,10 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
790
805
|
*/
|
|
791
806
|
const checkWriteTarget = (token: string) => {
|
|
792
807
|
if (token === "/dev/null") return;
|
|
793
|
-
const absolute =
|
|
808
|
+
const absolute = toAbsoluteBash(effectiveCwd, token, home);
|
|
794
809
|
const resolved = resolveForContainment(absolute);
|
|
795
810
|
const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
796
|
-
evidence.writes.push({ token, resolved, outsideCwd });
|
|
811
|
+
evidence.writes.push({ token, absolute, resolved, outsideCwd });
|
|
797
812
|
if (resolved === undefined) {
|
|
798
813
|
escalate(`writes to ${token}, which could not be resolved to a real path`);
|
|
799
814
|
} else if (outsideCwd) {
|
|
@@ -863,7 +878,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
863
878
|
// `~/.kube/config` through two tokens that each look harmless (N12).
|
|
864
879
|
for (const { value } of args) {
|
|
865
880
|
if (!value || value.startsWith("-")) continue;
|
|
866
|
-
const absolute =
|
|
881
|
+
const absolute = toAbsoluteBash(effectiveCwd, value, home);
|
|
867
882
|
if (isSensitivePath(value) || isSensitivePath(absolute)) {
|
|
868
883
|
// The *original* token, never the resolved/expanded form (N18).
|
|
869
884
|
if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
|
|
@@ -878,7 +893,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
878
893
|
if (name === "cd") {
|
|
879
894
|
const target = args.find((token) => !token.value.startsWith("-"))?.value;
|
|
880
895
|
if (target) {
|
|
881
|
-
effectiveCwd =
|
|
896
|
+
effectiveCwd = toAbsoluteBash(effectiveCwd, target, home);
|
|
882
897
|
escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
|
|
883
898
|
}
|
|
884
899
|
continue;
|
|
@@ -902,7 +917,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
902
917
|
continue;
|
|
903
918
|
}
|
|
904
919
|
const reason = gitEscalationReason(args, (dir) => {
|
|
905
|
-
const resolved = resolveForContainment(
|
|
920
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, dir, home));
|
|
906
921
|
return resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
907
922
|
});
|
|
908
923
|
if (reason) escalate(reason);
|
|
@@ -946,8 +961,8 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
946
961
|
}
|
|
947
962
|
for (const value of positionals) {
|
|
948
963
|
if (!looksLikePath(value)) continue;
|
|
949
|
-
const resolved = resolveForContainment(
|
|
950
|
-
if (resolved !== undefined && isWithin(containmentRoot, resolved)) continue;
|
|
964
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, value, home));
|
|
965
|
+
if (resolved !== undefined && (isWithin(containmentRoot, resolved) || readableRoots.some((root) => isWithin(root, resolved)))) continue;
|
|
951
966
|
if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
|
|
952
967
|
escalate(`reads ${value}, which is outside the working directory`, { outsideRead: true });
|
|
953
968
|
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// the hooks matcher (its `ccToolName` is the correct-direction, mcp-passthrough
|
|
17
17
|
// map; permissions/matcher.ts's is lower-cased and cannot recover casing).
|
|
18
18
|
import { ccToolName } from "../hooks/matcher.ts";
|
|
19
|
+
// The shell tools render as `{"<Tool>":"<command>"}` (lib/shell-tools.ts is the one list).
|
|
20
|
+
import { SHELL_TOOLS } from "../lib/shell-tools.ts";
|
|
19
21
|
|
|
20
22
|
export { ccToolName };
|
|
21
23
|
|
|
@@ -30,7 +32,13 @@ export { ccToolName };
|
|
|
30
32
|
export type TranscriptEntry =
|
|
31
33
|
| { kind: "user"; text: string }
|
|
32
34
|
| { kind: "tool"; tool: string; input: Record<string, unknown> }
|
|
33
|
-
| { kind: "denied"; tool: string; subject: string; rule: string }
|
|
35
|
+
| { kind: "denied"; tool: string; subject: string; rule: string }
|
|
36
|
+
/**
|
|
37
|
+
* Harness ground truth, Claude Code's `{"meta":{"gitStatus":{"clean":…}}}`
|
|
38
|
+
* line after a `git status` call (captured 2.1.276, findings §22): the
|
|
39
|
+
* classifier reads the tree's real state, not the model's account of it.
|
|
40
|
+
*/
|
|
41
|
+
| { kind: "meta"; gitStatus: { clean: boolean } };
|
|
34
42
|
|
|
35
43
|
/** Truncate one field so a single huge argument cannot dominate the transcript. */
|
|
36
44
|
export function clip(value: string, max: number): string {
|
|
@@ -47,12 +55,14 @@ function clipInput(input: Record<string, unknown>, max: number): Record<string,
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
|
-
* Render one entry as its compact JSON line.
|
|
51
|
-
* (the command string,
|
|
52
|
-
* `{"<Tool>":{…input…}}`
|
|
58
|
+
* Render one entry as its compact JSON line. The shell tools render as
|
|
59
|
+
* `{"Bash":"<command>"}` / `{"PowerShell":"<command>"}` (the command string,
|
|
60
|
+
* as Claude Code does); every other tool renders as `{"<Tool>":{…input…}}`
|
|
61
|
+
* with string fields clipped.
|
|
53
62
|
*/
|
|
54
63
|
function renderEntry(entry: TranscriptEntry, maxField: number): string {
|
|
55
64
|
if (entry.kind === "user") return JSON.stringify({ user: clip(entry.text, maxField) });
|
|
65
|
+
if (entry.kind === "meta") return JSON.stringify({ meta: { gitStatus: { clean: entry.gitStatus.clean } } });
|
|
56
66
|
if (entry.kind === "denied") {
|
|
57
67
|
return JSON.stringify({
|
|
58
68
|
denied_by_permission_rule: { tool: ccToolName(entry.tool), attempted: clip(entry.subject, maxField), rule: entry.rule },
|
|
@@ -60,7 +70,7 @@ function renderEntry(entry: TranscriptEntry, maxField: number): string {
|
|
|
60
70
|
}
|
|
61
71
|
const name = ccToolName(entry.tool);
|
|
62
72
|
const command = entry.input.command;
|
|
63
|
-
if (entry.tool
|
|
73
|
+
if (SHELL_TOOLS.has(entry.tool) && typeof command === "string") {
|
|
64
74
|
return JSON.stringify({ [name]: clip(command, maxField) });
|
|
65
75
|
}
|
|
66
76
|
return JSON.stringify({ [name]: clipInput(entry.input, maxField) });
|
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
* long-running work over TASK_REGISTER_CHANNEL at runtime, so task_output and
|
|
7
7
|
* task_stop address every background task in the session regardless of which
|
|
8
8
|
* extension started it. Events and completions are delivered as steered
|
|
9
|
-
*
|
|
9
|
+
* task notifications (lib/notifications.ts), never as user input; a fired
|
|
10
|
+
* wakeup or loop tick re-invokes the session with its prompt verbatim.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { Type } from "typebox";
|
|
15
15
|
import { whenAborted } from "../lib/abort.ts";
|
|
16
16
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
17
17
|
import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
|
|
18
|
-
import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree } from "../lib/process-tree.ts";
|
|
18
|
+
import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
|
|
19
19
|
import { sessionAlive } from "../lib/session-lifecycle.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { bashSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
|
|
21
|
+
import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent, scheduledTaskComponent } from "../lib/tui-render.ts";
|
|
21
22
|
import {
|
|
22
23
|
type BackgroundTask,
|
|
23
24
|
BackgroundRegistry,
|
|
@@ -35,16 +36,28 @@ import {
|
|
|
35
36
|
MIN_DELAY_SECONDS,
|
|
36
37
|
parseLoopArgs,
|
|
37
38
|
} from "./wakeup.ts";
|
|
38
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
createTaskNotifier,
|
|
41
|
+
monitorEndedSummary,
|
|
42
|
+
monitorEventSummary,
|
|
43
|
+
oneShotNote,
|
|
44
|
+
sessionOutlivesTurn,
|
|
45
|
+
TASK_OUTPUT_DELIVERED_CHANNEL,
|
|
46
|
+
type TaskOutputDelivered,
|
|
47
|
+
taskNotification,
|
|
48
|
+
taskStatusOf,
|
|
49
|
+
} from "../lib/notifications.ts";
|
|
39
50
|
import {
|
|
40
51
|
batchSize,
|
|
41
52
|
emptyBatch,
|
|
42
|
-
|
|
53
|
+
formatMonitorEvents,
|
|
43
54
|
MONITOR_BATCH_BUSY_MS,
|
|
55
|
+
MONITOR_BATCH_MAX_CHARS,
|
|
44
56
|
MONITOR_BATCH_IDLE_MS,
|
|
45
57
|
type MonitorBatch,
|
|
46
58
|
pushEvent,
|
|
47
59
|
} from "./monitor-batch.ts";
|
|
60
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
48
61
|
|
|
49
62
|
const OUTPUT_CAP = 30_000;
|
|
50
63
|
const STORED_OUTPUT_CAP = 200_000;
|
|
@@ -103,16 +116,26 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
103
116
|
live.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
|
|
104
117
|
};
|
|
105
118
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
119
|
+
// The wire frames are model-facing; the transcript shows a compact headline
|
|
120
|
+
// instead (ctrl+o expands). One registration covers every emitter of
|
|
121
|
+
// `task-notification` (bash uses it too). A fired wakeup or loop tick is the
|
|
122
|
+
// prompt verbatim, shown behind Claude Code's "Running scheduled task" line.
|
|
123
|
+
pi.registerMessageRenderer("task-notification", (message, { expanded }, theme) =>
|
|
124
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
125
|
+
);
|
|
126
|
+
for (const customType of ["wakeup", "loop"]) {
|
|
110
127
|
pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
|
|
111
|
-
|
|
128
|
+
scheduledTaskComponent(theme, customMessageText(message.content), (message as { timestamp?: number }).timestamp ?? Date.now(), expanded),
|
|
112
129
|
);
|
|
113
130
|
}
|
|
131
|
+
// The dynamic /loop's opening turn is the user's command, not a fired task.
|
|
132
|
+
pi.registerMessageRenderer("loop-start", (message, { expanded }, theme) =>
|
|
133
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
134
|
+
);
|
|
114
135
|
|
|
115
|
-
|
|
136
|
+
// A monitor's end the model already read through task_output is withdrawn
|
|
137
|
+
// like a shell's (lib/notifications.ts).
|
|
138
|
+
const notify = createTaskNotifier(pi, { withdrawOnDelivery: true });
|
|
116
139
|
|
|
117
140
|
pi.registerTool({
|
|
118
141
|
name: "monitor",
|
|
@@ -137,7 +160,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
137
160
|
),
|
|
138
161
|
),
|
|
139
162
|
}),
|
|
140
|
-
async execute(
|
|
163
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
141
164
|
lastCtx = ctx;
|
|
142
165
|
if (Boolean(params.command) === Boolean(params.ws)) {
|
|
143
166
|
return {
|
|
@@ -170,10 +193,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
170
193
|
if (batchSize(pending) === 0) return;
|
|
171
194
|
const batch = pending;
|
|
172
195
|
pending = emptyBatch();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
// CC's mid-run monitor batch: no status, the lines in `<event>`.
|
|
197
|
+
notify(
|
|
198
|
+
"task-notification",
|
|
199
|
+
taskNotification({ kind: "monitor", taskId: id, summary: monitorEventSummary(params.description), result: formatMonitorEvents(id, batch) }),
|
|
200
|
+
{ taskId: id, events: batchSize(batch) },
|
|
201
|
+
);
|
|
177
202
|
};
|
|
178
203
|
|
|
179
204
|
const onEvent = (line: string) => {
|
|
@@ -200,9 +225,19 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
200
225
|
if (!alive() || oneShot) return;
|
|
201
226
|
flush();
|
|
202
227
|
updateWidget();
|
|
228
|
+
// CC's monitor end: the recent tail rides `<event>` under the ended summary.
|
|
229
|
+
const ccStatus = taskStatusOf(finalStatus);
|
|
230
|
+
const recent = tail(stored, MONITOR_BATCH_MAX_CHARS).trim();
|
|
203
231
|
notify(
|
|
204
232
|
"task-notification",
|
|
205
|
-
|
|
233
|
+
taskNotification({
|
|
234
|
+
kind: "monitor",
|
|
235
|
+
taskId: id,
|
|
236
|
+
toolUseId: toolCallId,
|
|
237
|
+
status: ccStatus,
|
|
238
|
+
summary: monitorEndedSummary(params.description, ccStatus, eventCount > 0, note),
|
|
239
|
+
result: recent || undefined,
|
|
240
|
+
}),
|
|
206
241
|
{ taskId: id, status: finalStatus },
|
|
207
242
|
);
|
|
208
243
|
};
|
|
@@ -214,13 +249,22 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
214
249
|
// leader — with zsh a `cmd; echo` or a pipeline otherwise lives on as an
|
|
215
250
|
// orphan holding the stdout pipe, so the task never finishes and, in a
|
|
216
251
|
// one-shot run, the process cannot exit (LIFECYCLE-REVIEW M2, measured).
|
|
217
|
-
|
|
252
|
+
// The session's bash (Git Bash on Windows) — lib/shell-spawn.ts.
|
|
253
|
+
const bash = bashSpawn();
|
|
254
|
+
if (!bash.spawn) {
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text", text: `Cannot start the monitor: ${bash.error ?? "no bash shell was found"}` }],
|
|
257
|
+
details: {},
|
|
258
|
+
isError: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const child = spawnShellCommand(bash.spawn, params.command, {
|
|
218
262
|
cwd: ctx.cwd,
|
|
219
263
|
...detachedSpawnOptions(),
|
|
220
264
|
stdio: ["ignore", "pipe", "pipe"],
|
|
221
265
|
});
|
|
222
266
|
let buffer = "";
|
|
223
|
-
child.stdout
|
|
267
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
224
268
|
buffer += chunk.toString();
|
|
225
269
|
let idx: number;
|
|
226
270
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
@@ -229,15 +273,17 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
229
273
|
if (line) onEvent(line);
|
|
230
274
|
}
|
|
231
275
|
});
|
|
232
|
-
child.stderr
|
|
276
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
233
277
|
stored = tail(`${stored}${chunk.toString()}`, STORED_OUTPUT_CAP);
|
|
234
278
|
});
|
|
235
|
-
|
|
236
|
-
child.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
279
|
+
// Exit plus a short stdio grace, not `close` (lib/process-tree.ts).
|
|
280
|
+
waitForChildExit(child).then(
|
|
281
|
+
({ code }) =>
|
|
282
|
+
end(
|
|
283
|
+
stopRequested ? "stopped" : code === 0 ? "completed" : "failed",
|
|
284
|
+
code !== null && code !== 0 ? `exit code ${code}` : undefined,
|
|
285
|
+
),
|
|
286
|
+
(error: Error) => end("failed", error.message),
|
|
241
287
|
);
|
|
242
288
|
stop = () => {
|
|
243
289
|
stopRequested = true;
|
|
@@ -328,7 +374,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
328
374
|
content: [
|
|
329
375
|
{
|
|
330
376
|
type: "text",
|
|
331
|
-
text: `Monitor ${id} started (${params.description}). Events arrive as
|
|
377
|
+
text: `Monitor ${id} started (${params.description}). Events arrive as task notifications; stop with task_stop, inspect with task_output.`,
|
|
332
378
|
},
|
|
333
379
|
],
|
|
334
380
|
details: { taskId: id },
|
|
@@ -341,7 +387,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
341
387
|
label: "Task Output",
|
|
342
388
|
...ccToolRenderers("Task Output"),
|
|
343
389
|
description:
|
|
344
|
-
"Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion arrives as a
|
|
390
|
+
"Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion arrives as a task notification naming the output file; call this for the output itself, when your next step needs the result now, or for a mid-run peek.",
|
|
345
391
|
parameters: Type.Object({
|
|
346
392
|
task_id: Type.String({ description: "The task id to get output from" }),
|
|
347
393
|
block: Type.Optional(Type.Boolean({ description: "Wait for completion (default true)" })),
|
|
@@ -377,6 +423,9 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
377
423
|
updateWidget();
|
|
378
424
|
const header = formatTaskLine(task);
|
|
379
425
|
const body = tail(task.output(), OUTPUT_CAP) || "(no output yet)";
|
|
426
|
+
// The model now holds a finished task's output: a completion notification
|
|
427
|
+
// still waiting to go out for it is redundant (lib/notifications.ts).
|
|
428
|
+
if (task.status !== "running") pi.events.emit(TASK_OUTPUT_DELIVERED_CHANNEL, { taskId: task.id } satisfies TaskOutputDelivered);
|
|
380
429
|
return {
|
|
381
430
|
content: [{ type: "text", text: `${header}\n\n${body}` }],
|
|
382
431
|
details: { taskId: task.id, status: task.status, logPath: task.logPath },
|
|
@@ -430,7 +479,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
430
479
|
title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
|
|
431
480
|
}),
|
|
432
481
|
description:
|
|
433
|
-
"Schedule when to resume work on a self-paced recurring task — the dynamic mode of the /loop command. After `delaySeconds` (clamped to [60, 3600]) the
|
|
482
|
+
"Schedule when to resume work on a self-paced recurring task — the dynamic mode of the /loop command. After `delaySeconds` (clamped to [60, 3600]) the harness re-invokes you with the given prompt, verbatim, as the next turn's input. One wakeup is pending at a time — scheduling again replaces it; {stop: true} ends the loop and cancels any pending wakeup.\n\nPass the same task back via `prompt` each turn so the next firing repeats it. Set `noop: true` when nothing changed this tick (you checked and there's nothing to report); `noop: false` when something happened worth keeping. Pick `delaySeconds` from what you're actually waiting for: poll external state (a CI run, a deploy) at the rate it changes; for a quiet idle heartbeat prefer a long delay (1200s+). Do NOT schedule a short wakeup just to poll background work you started here — its completion already notifies you.",
|
|
434
483
|
parameters: Type.Object({
|
|
435
484
|
delaySeconds: Type.Optional(Type.Number({ description: "Seconds from now to wake up, clamped to [60, 3600]. Required unless stop is true" })),
|
|
436
485
|
prompt: Type.Optional(Type.String({ description: "The task to continue when the wakeup fires. Required unless stop is true" })),
|
|
@@ -488,9 +537,9 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
488
537
|
pi.events.emit(DEFER_CHANNEL, { name, keywords });
|
|
489
538
|
}
|
|
490
539
|
|
|
491
|
-
pi
|
|
540
|
+
registerLocalCommand(pi, "background", {
|
|
492
541
|
description: "List background tasks (monitors, background subagents)",
|
|
493
|
-
handler: async (
|
|
542
|
+
handler: async (args, ctx) => {
|
|
494
543
|
const tasks = registry.list();
|
|
495
544
|
ctx.ui.notify(tasks.length ? tasks.map(formatTaskLine).join("\n") : "No background tasks.", "info");
|
|
496
545
|
},
|
|
@@ -509,7 +558,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
509
558
|
}
|
|
510
559
|
};
|
|
511
560
|
|
|
512
|
-
pi
|
|
561
|
+
registerLocalCommand(pi, "loop", {
|
|
513
562
|
description: "Repeat a task on a loop: `/loop 5m <task>` (fixed interval) or `/loop <task>` (self-paced). `/loop stop` ends it.",
|
|
514
563
|
handler: async (args, ctx) => {
|
|
515
564
|
lastCtx = ctx;
|
|
@@ -580,7 +629,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
580
629
|
activatedWakeupTool = true;
|
|
581
630
|
}
|
|
582
631
|
ctx.ui.notify(`Self-paced loop started: ${parsed.task}. Stop with /loop stop.`, "info");
|
|
583
|
-
notify("loop", buildDynamicLoopPrompt(parsed.task), {});
|
|
632
|
+
notify("loop-start", buildDynamicLoopPrompt(parsed.task), {});
|
|
584
633
|
},
|
|
585
634
|
});
|
|
586
635
|
|
|
@@ -37,9 +37,12 @@ export function batchSize(batch: MonitorBatch): number {
|
|
|
37
37
|
return batch.shown.length + batch.overflow;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The `<event>` body for one flushed batch: the lines, then how many more
|
|
42
|
+
* arrived (the full stream stays behind `task_output`). The monitor's
|
|
43
|
+
* description rides the notification's `<summary>`, not this text.
|
|
44
|
+
*/
|
|
45
|
+
export function formatMonitorEvents(id: string, batch: MonitorBatch): string {
|
|
43
46
|
const lines: string[] = [];
|
|
44
47
|
let chars = 0;
|
|
45
48
|
let hidden = batch.overflow;
|
|
@@ -52,7 +55,6 @@ export function formatMonitorBatch(id: string, description: string, batch: Monit
|
|
|
52
55
|
lines.push(shown);
|
|
53
56
|
chars += shown.length + 1;
|
|
54
57
|
}
|
|
55
|
-
const head = `Monitor ${id} (${description}) emitted ${total} event(s):`;
|
|
56
58
|
const more = hidden > 0 ? `\n… +${hidden} more line(s) not shown — task_output ${id} has the full stream` : "";
|
|
57
|
-
return `${
|
|
59
|
+
return `${lines.join("\n")}${more}`;
|
|
58
60
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* schedule_wakeup helpers (pure) — Claude Code's self-paced /loop timer.
|
|
3
|
+
*
|
|
4
|
+
* A fired wakeup or loop tick is a RE-INVOCATION, not a notification: Claude
|
|
5
|
+
* Code enqueues the scheduled prompt verbatim as the next turn's input, behind
|
|
6
|
+
* a dim "Running scheduled task" line in the TUI (findings §21), so the model
|
|
7
|
+
* reads its own task back exactly as it wrote it. These builders therefore
|
|
8
|
+
* return the prompt as-is; the TUI shows the line (`scheduledTaskComponent`).
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
|
-
import { systemNotification } from "../lib/notifications.ts";
|
|
6
|
-
|
|
7
11
|
export const MIN_DELAY_SECONDS = 60;
|
|
8
12
|
export const MAX_DELAY_SECONDS = 3600;
|
|
9
13
|
|
|
@@ -42,37 +46,27 @@ export function parseLoopArgs(raw: string): ParsedLoop {
|
|
|
42
46
|
return { task: raw.trim() };
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
/**
|
|
46
|
-
function framedLoop(lead: string, task: string): string {
|
|
47
|
-
return systemNotification([lead, "", task].join("\n"));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The follow-up a fixed-interval `/loop` tick delivers. */
|
|
49
|
+
/** The turn input a fixed-interval `/loop` tick delivers: the task, verbatim (CC's cron fire). */
|
|
51
50
|
export function buildLoopMessage(task: string): string {
|
|
52
|
-
return
|
|
53
|
-
"Loop tick — run the task below now. This repeats automatically on the interval and keeps firing until the user runs `/loop stop`.",
|
|
54
|
-
task,
|
|
55
|
-
);
|
|
51
|
+
return task;
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The opening turn of a dynamic (self-paced) `/loop`: the user's command, so it
|
|
56
|
+
* reads as their instruction — drive schedule_wakeup, pass the task back each
|
|
57
|
+
* time. Unframed: it is the user's `/loop`, not a harness event.
|
|
58
|
+
*/
|
|
59
59
|
export function buildDynamicLoopPrompt(task: string): string {
|
|
60
|
-
return
|
|
60
|
+
return [
|
|
61
61
|
"Self-paced loop started. Work the task below now. When this iteration is done, call schedule_wakeup to schedule the next one — choose delaySeconds by what you're waiting for, and pass the same task back as `prompt`. End the loop with schedule_wakeup {stop: true} when the task is complete or the user says to stop.",
|
|
62
|
+
"",
|
|
62
63
|
task,
|
|
63
|
-
);
|
|
64
|
+
].join("\n");
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/** The
|
|
67
|
+
/** The turn input a fired wakeup delivers: the scheduled prompt, verbatim (CC re-invokes with it). */
|
|
67
68
|
export function buildWakeupMessage(request: WakeupRequest): string {
|
|
68
|
-
return
|
|
69
|
-
[
|
|
70
|
-
`Scheduled wake-up fired (reason given when scheduled: ${request.reason}).`,
|
|
71
|
-
"Continue the task below; when done, either schedule the next wake-up with schedule_wakeup or end the loop with schedule_wakeup {stop: true}.",
|
|
72
|
-
"",
|
|
73
|
-
request.prompt,
|
|
74
|
-
].join("\n"),
|
|
75
|
-
);
|
|
69
|
+
return request.prompt;
|
|
76
70
|
}
|
|
77
71
|
|
|
78
72
|
export function describeSchedule(request: WakeupRequest): string {
|