surgent 0.7.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,28 @@
1
+ export type AgentMode = "assistant" | "yolo" | "restricted";
2
+
3
+ export interface AgentMeta {
4
+ description: string;
5
+ tools?: string[];
6
+ mcp_tools?: string[];
7
+ skills?: string[];
8
+ bash?: string[];
9
+ "files.read"?: string[];
10
+ "files.write"?: string[];
11
+ model?: string;
12
+ thinking_level?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
13
+ }
14
+
15
+ export interface Agent {
16
+ name: string;
17
+ meta: AgentMeta;
18
+ body: string;
19
+ filePath: string;
20
+ }
21
+
22
+ export interface SettingsSchema {
23
+ agent?: {
24
+ mode?: AgentMode;
25
+ meta?: Record<string, Partial<AgentMeta>>;
26
+ };
27
+ [key: string]: unknown;
28
+ }
@@ -0,0 +1,173 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { getPiPath } from "../utils.js";
6
+ import type { Repo } from "./index.js";
7
+
8
+ const CHECKPOINT_SOURCE_KEY = "surgent.checkpointSource";
9
+
10
+ export async function getCheckpointRepo(pi: ExtensionAPI, cwd: string): Promise<Repo | undefined> {
11
+ const projectRootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
12
+ const projectRoot = projectRootResult.stdout.trim();
13
+ if (projectRootResult.code !== 0 || !projectRoot) return;
14
+
15
+ return {
16
+ projectRoot,
17
+ directory: getPiPath(
18
+ "checkpoints",
19
+ "global",
20
+ createHash("sha256").update(projectRoot).digest("hex"),
21
+ ),
22
+ };
23
+ }
24
+
25
+ export async function openCheckpointRepo(pi: ExtensionAPI, cwd: string): Promise<Repo | undefined> {
26
+ const repo = await getCheckpointRepo(pi, cwd);
27
+ if (!repo) return;
28
+
29
+ const sourceGitDirResult = await pi.exec(
30
+ "git",
31
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
32
+ { cwd: repo.projectRoot },
33
+ );
34
+ const sourceGitDir = sourceGitDirResult.stdout.trim();
35
+ if (sourceGitDirResult.code !== 0 || !sourceGitDir) return;
36
+ let needsInitialization = false;
37
+
38
+ try {
39
+ await access(join(repo.directory, ".git"));
40
+ } catch {
41
+ needsInitialization = true;
42
+ }
43
+
44
+ if (!needsInitialization) {
45
+ const configuredSourceResult = await runCheckpointGit(pi, repo, [
46
+ "config",
47
+ "--get",
48
+ CHECKPOINT_SOURCE_KEY,
49
+ ]);
50
+ if (
51
+ configuredSourceResult.code !== 0 ||
52
+ configuredSourceResult.stdout.trim() !== sourceGitDir
53
+ ) {
54
+ await rm(repo.directory, { recursive: true, force: true });
55
+ needsInitialization = true;
56
+ }
57
+ }
58
+
59
+ if (needsInitialization) {
60
+ const initialized = await initializeCheckpointRepo(pi, repo, sourceGitDir);
61
+ if (!initialized) return;
62
+ }
63
+
64
+ await syncCheckpointIgnore(pi, repo);
65
+ return repo;
66
+ }
67
+
68
+ export async function runCheckpointGit(pi: ExtensionAPI, repo: Repo, args: string[]) {
69
+ return pi.exec(
70
+ "git",
71
+ [`--git-dir=${join(repo.directory, ".git")}`, `--work-tree=${repo.projectRoot}`, ...args],
72
+ { cwd: repo.projectRoot },
73
+ );
74
+ }
75
+
76
+ export async function gcCheckpointRepo(pi: ExtensionAPI, repo: Repo) {
77
+ return runCheckpointGit(pi, repo, ["gc", "--auto"]);
78
+ }
79
+
80
+ async function initializeCheckpointRepo(
81
+ pi: ExtensionAPI,
82
+ repo: Repo,
83
+ sourceGitDir: string,
84
+ ): Promise<boolean> {
85
+ try {
86
+ await access(join(sourceGitDir, "objects"));
87
+ await mkdir(dirname(repo.directory), { recursive: true });
88
+ } catch {
89
+ return false;
90
+ }
91
+
92
+ const initializeResult = await pi.exec("git", ["init", repo.directory], {
93
+ cwd: repo.projectRoot,
94
+ });
95
+ if (initializeResult.code !== 0) return false;
96
+
97
+ for (const config of [
98
+ { key: "core.autocrlf", value: "false" },
99
+ { key: "core.longpaths", value: "true" },
100
+ { key: "core.symlinks", value: "true" },
101
+ { key: "core.fsmonitor", value: "false" },
102
+ { key: CHECKPOINT_SOURCE_KEY, value: sourceGitDir },
103
+ ]) {
104
+ const configResult = await runCheckpointGit(pi, repo, ["config", config.key, config.value]);
105
+ if (configResult.code !== 0) return false;
106
+ }
107
+
108
+ const checkpointGitDir = join(repo.directory, ".git");
109
+ try {
110
+ await mkdir(join(checkpointGitDir, "objects", "info"), { recursive: true });
111
+ await writeFile(
112
+ join(checkpointGitDir, "objects", "info", "alternates"),
113
+ `${join(sourceGitDir, "objects")}\n`,
114
+ "utf8",
115
+ );
116
+ } catch {
117
+ return false;
118
+ }
119
+
120
+ const sourceIndexResult = await pi.exec(
121
+ "git",
122
+ ["rev-parse", "--path-format=absolute", "--git-path", "index"],
123
+ { cwd: repo.projectRoot },
124
+ );
125
+ let hasSeededIndex = false;
126
+ if (sourceIndexResult.code === 0 && sourceIndexResult.stdout.trim()) {
127
+ try {
128
+ await copyFile(sourceIndexResult.stdout.trim(), join(checkpointGitDir, "index"));
129
+ const indexResult = await runCheckpointGit(pi, repo, ["ls-files", "-z"]);
130
+ hasSeededIndex = indexResult.code === 0;
131
+ if (!hasSeededIndex) {
132
+ await rm(join(checkpointGitDir, "index"), { force: true });
133
+ }
134
+ } catch {
135
+ await rm(join(checkpointGitDir, "index"), { force: true });
136
+ }
137
+ }
138
+
139
+ if (!hasSeededIndex) {
140
+ const sourceHeadResult = await pi.exec("git", ["rev-parse", "HEAD"], {
141
+ cwd: repo.projectRoot,
142
+ });
143
+ if (sourceHeadResult.code === 0 && sourceHeadResult.stdout.trim()) {
144
+ const seedResult = await runCheckpointGit(pi, repo, [
145
+ "read-tree",
146
+ sourceHeadResult.stdout.trim(),
147
+ ]);
148
+ if (seedResult.code !== 0) return false;
149
+ }
150
+ }
151
+
152
+ return true;
153
+ }
154
+
155
+ async function syncCheckpointIgnore(pi: ExtensionAPI, repo: Repo) {
156
+ const sourceIgnoreResult = await pi.exec(
157
+ "git",
158
+ ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
159
+ { cwd: repo.projectRoot },
160
+ );
161
+ if (sourceIgnoreResult.code !== 0 || !sourceIgnoreResult.stdout.trim()) return;
162
+
163
+ const checkpointIgnoreFile = join(repo.directory, ".git", "info", "exclude");
164
+ const sourceIgnoreFile = sourceIgnoreResult.stdout.trim();
165
+ const sourceIgnoreContents = await readFile(sourceIgnoreFile, "utf8").catch(() => undefined);
166
+ if (sourceIgnoreContents === undefined) {
167
+ await rm(checkpointIgnoreFile, { force: true });
168
+ return;
169
+ }
170
+
171
+ await mkdir(dirname(checkpointIgnoreFile), { recursive: true });
172
+ await writeFile(checkpointIgnoreFile, sourceIgnoreContents, "utf8");
173
+ }
@@ -0,0 +1,117 @@
1
+ import { join } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { gcCheckpointRepo, openCheckpointRepo } from "./git.js";
4
+ import { createSnapshot, retainSnapshot, restoreSnapshot } from "./snapshot.js";
5
+ import {
6
+ BASE_CHECKPOINT_KEY,
7
+ readCheckpointStore,
8
+ shouldOfferRestore,
9
+ writeCheckpointStore,
10
+ } from "./store.js";
11
+
12
+ export interface Repo {
13
+ projectRoot: string;
14
+ directory: string;
15
+ }
16
+
17
+ export default function (pi: ExtensionAPI) {
18
+ const checkpoints = new Map<string, string>();
19
+ const pendingCheckpoints = new Map<string, { entryId: string; tree: string }>();
20
+ let repo: Repo | undefined;
21
+
22
+ async function saveCheckpoints(ctx: ExtensionContext) {
23
+ if (!repo) return;
24
+ await writeCheckpointStore(
25
+ join(repo.directory, "entries.json"),
26
+ ctx.sessionManager.getSessionId(),
27
+ checkpoints,
28
+ );
29
+ }
30
+
31
+ async function restoreCheckpoint(
32
+ ctx: ExtensionContext,
33
+ targetEntryId: string,
34
+ currentEntryId: string | null,
35
+ ): Promise<{ cancel: boolean } | void> {
36
+ if (!ctx.hasUI || !repo) return;
37
+
38
+ const decision = shouldOfferRestore(targetEntryId, currentEntryId, ctx, checkpoints);
39
+ if (!decision.shouldRestore || !decision.tree) return;
40
+
41
+ const options = ["Yes, restore code to that point", "No, keep current code"];
42
+ const choice = await ctx.ui.select("Restore code state?", options);
43
+ if (choice !== options[0]) return;
44
+
45
+ const restoreResult = await restoreSnapshot(pi, repo, decision.tree);
46
+ if (restoreResult.code !== 0) {
47
+ const reason = restoreResult.stderr.trim() || restoreResult.stdout.trim();
48
+ ctx.ui.notify(
49
+ reason
50
+ ? `Checkpoint restore failed: ${reason}`
51
+ : "Checkpoint restore failed due to git error.",
52
+ "error",
53
+ );
54
+ return { cancel: true };
55
+ }
56
+
57
+ ctx.ui.notify("Code restored to checkpoint", "info");
58
+ }
59
+
60
+ pi.on("session_start", async (_event, ctx) => {
61
+ checkpoints.clear();
62
+ pendingCheckpoints.clear();
63
+ repo = await openCheckpointRepo(pi, ctx.cwd);
64
+ if (!repo) return;
65
+
66
+ const store = await readCheckpointStore(join(repo.directory, "entries.json"));
67
+ const sessionCheckpoints = store[ctx.sessionManager.getSessionId()] ?? {};
68
+ for (const [entryId, tree] of Object.entries(sessionCheckpoints)) {
69
+ checkpoints.set(entryId, tree);
70
+ }
71
+
72
+ if (checkpoints.has(BASE_CHECKPOINT_KEY)) return;
73
+ const tree = await createSnapshot(pi, repo);
74
+ if (tree && (await retainSnapshot(pi, repo, tree))) {
75
+ checkpoints.set(BASE_CHECKPOINT_KEY, tree);
76
+ }
77
+ });
78
+
79
+ pi.on("tool_call", async (event, ctx) => {
80
+ if ((event.toolName !== "write" && event.toolName !== "edit") || !repo) return;
81
+
82
+ const entryId = ctx.sessionManager.getLeafId();
83
+ if (!entryId) return;
84
+
85
+ const tree = await createSnapshot(pi, repo);
86
+ if (tree) pendingCheckpoints.set(event.toolCallId, { entryId, tree });
87
+ });
88
+
89
+ pi.on("tool_result", async (event) => {
90
+ const pendingCheckpoint = pendingCheckpoints.get(event.toolCallId);
91
+ if (!pendingCheckpoint) return;
92
+ pendingCheckpoints.delete(event.toolCallId);
93
+ if (event.isError || !repo) return;
94
+
95
+ const retained = await retainSnapshot(pi, repo, pendingCheckpoint.tree);
96
+ if (retained) checkpoints.set(pendingCheckpoint.entryId, pendingCheckpoint.tree);
97
+ });
98
+
99
+ pi.on("session_before_tree", (event, ctx) => {
100
+ const { targetId, oldLeafId } = event.preparation;
101
+ return restoreCheckpoint(ctx, targetId, oldLeafId);
102
+ });
103
+
104
+ pi.on("session_before_fork", (event, ctx) => {
105
+ return restoreCheckpoint(ctx, event.entryId, ctx.sessionManager.getLeafId());
106
+ });
107
+
108
+ pi.on("agent_end", async (_event, ctx) => {
109
+ await saveCheckpoints(ctx);
110
+ });
111
+
112
+ pi.on("session_shutdown", async (_event, ctx) => {
113
+ await saveCheckpoints(ctx);
114
+ if (!repo) return;
115
+ await gcCheckpointRepo(pi, repo);
116
+ });
117
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { runCheckpointGit } from "./git.js";
3
+ import { stageCheckpoint } from "./stage.js";
4
+ import type { Repo } from "./index.js";
5
+
6
+ export async function createSnapshot(pi: ExtensionAPI, repo: Repo): Promise<string | undefined> {
7
+ const staged = await stageCheckpoint(pi, repo);
8
+ if (!staged) return;
9
+
10
+ const treeResult = await runCheckpointGit(pi, repo, ["write-tree"]);
11
+ if (treeResult.code !== 0) return;
12
+
13
+ const tree = treeResult.stdout.trim();
14
+ return /^[0-9a-f]{40,64}$/i.test(tree) ? tree : undefined;
15
+ }
16
+
17
+ export async function retainSnapshot(pi: ExtensionAPI, repo: Repo, tree: string): Promise<boolean> {
18
+ const referenceResult = await runCheckpointGit(pi, repo, [
19
+ "update-ref",
20
+ `refs/surgent/checkpoints/${tree}`,
21
+ tree,
22
+ ]);
23
+ return referenceResult.code === 0;
24
+ }
25
+
26
+ export async function restoreSnapshot(pi: ExtensionAPI, repo: Repo, tree: string) {
27
+ return runCheckpointGit(pi, repo, ["read-tree", "--reset", "-u", tree]);
28
+ }
@@ -0,0 +1,59 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { runCheckpointGit } from "./git.js";
5
+ import type { Repo } from "./index.js";
6
+
7
+ const UNTRACKED_FILE_LIMIT = 2 * 1024 * 1024;
8
+ const STAGE_BATCH_SIZE = 100;
9
+
10
+ export async function stageCheckpoint(pi: ExtensionAPI, repo: Repo): Promise<boolean> {
11
+ const changedResult = await runCheckpointGit(pi, repo, [
12
+ "diff-files",
13
+ "--name-only",
14
+ "-z",
15
+ "--",
16
+ ".",
17
+ ]);
18
+ if (changedResult.code !== 0) return false;
19
+
20
+ const untrackedResult = await runCheckpointGit(pi, repo, [
21
+ "ls-files",
22
+ "--full-name",
23
+ "--others",
24
+ "--exclude-standard",
25
+ "-z",
26
+ "--",
27
+ ".",
28
+ ]);
29
+ if (untrackedResult.code !== 0) return false;
30
+
31
+ const filePaths = changedResult.stdout.split("\0").filter(Boolean);
32
+
33
+ await Promise.all(
34
+ untrackedResult.stdout.split("\0").map(async (filePath) => {
35
+ if (!filePath) return;
36
+ try {
37
+ const fileStatus = await lstat(join(repo.projectRoot, filePath));
38
+ if (fileStatus.isFile() && fileStatus.size > UNTRACKED_FILE_LIMIT) return;
39
+ filePaths.push(filePath);
40
+ } catch {
41
+ return; // file disappeared before staging.
42
+ }
43
+ }),
44
+ );
45
+
46
+ for (let start = 0; start < filePaths.length; start += STAGE_BATCH_SIZE) {
47
+ const stageResult = await runCheckpointGit(pi, repo, [
48
+ "add",
49
+ "--all",
50
+ "--",
51
+ ...filePaths
52
+ .slice(start, start + STAGE_BATCH_SIZE)
53
+ .map((filePath) => `:(top,literal)${filePath}`),
54
+ ]);
55
+ if (stageResult.code !== 0) return false;
56
+ }
57
+
58
+ return true;
59
+ }
@@ -0,0 +1,108 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { isUuidv7, readJson } from "../utils.js";
5
+
6
+ export const BASE_CHECKPOINT_KEY = "__base__";
7
+
8
+ export async function readCheckpointStore(
9
+ filePath: string,
10
+ ): Promise<Record<string, Record<string, string>>> {
11
+ const data = await readJson<unknown>(filePath, {});
12
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {};
13
+
14
+ const store: Record<string, Record<string, string>> = {};
15
+ for (const [sessionId, checkpoints] of Object.entries(data)) {
16
+ if (!checkpoints || typeof checkpoints !== "object" || Array.isArray(checkpoints)) continue;
17
+
18
+ const sessionCheckpoints: Record<string, string> = {};
19
+ for (const [entryId, tree] of Object.entries(checkpoints)) {
20
+ if (typeof tree !== "string") continue;
21
+ const checkpointTree = tree.trim();
22
+ if (!/^[0-9a-f]{40,64}$/i.test(checkpointTree)) continue;
23
+ sessionCheckpoints[entryId] = checkpointTree;
24
+ }
25
+ store[sessionId] = sessionCheckpoints;
26
+ }
27
+
28
+ return store;
29
+ }
30
+
31
+ export async function writeCheckpointStore(
32
+ filePath: string,
33
+ sessionId: string,
34
+ checkpoints: Map<string, string>,
35
+ ) {
36
+ const store = await readCheckpointStore(filePath);
37
+ if (checkpoints.size === 0) {
38
+ delete store[sessionId];
39
+ } else {
40
+ store[sessionId] = Object.fromEntries(checkpoints);
41
+ }
42
+
43
+ await persistCheckpointStore(filePath, store);
44
+ }
45
+
46
+ export async function pruneCheckpointStore(
47
+ filePath: string,
48
+ sessionIds: Set<string>,
49
+ ): Promise<string[]> {
50
+ const store = await readCheckpointStore(filePath);
51
+ const removedTrees = new Set<string>();
52
+ let hasChanges = false;
53
+
54
+ for (const [sessionId, checkpoints] of Object.entries(store)) {
55
+ if (!isUuidv7(sessionId) || sessionIds.has(sessionId)) continue;
56
+ for (const tree of Object.values(checkpoints)) {
57
+ removedTrees.add(tree);
58
+ }
59
+ delete store[sessionId];
60
+ hasChanges = true;
61
+ }
62
+
63
+ if (!hasChanges) return [];
64
+ await persistCheckpointStore(filePath, store);
65
+
66
+ const activeTrees = new Set(
67
+ Object.values(store).flatMap((checkpoints) => Object.values(checkpoints)),
68
+ );
69
+ return [...removedTrees].filter((tree) => !activeTrees.has(tree));
70
+ }
71
+
72
+ async function persistCheckpointStore(
73
+ filePath: string,
74
+ store: Record<string, Record<string, string>>,
75
+ ) {
76
+ await mkdir(dirname(filePath), { recursive: true });
77
+ await writeFile(filePath, JSON.stringify(store, null, 2) + "\n", "utf8");
78
+ }
79
+
80
+ export function findCheckpoint(
81
+ entryId: string | null,
82
+ ctx: ExtensionContext,
83
+ checkpoints: Map<string, string>,
84
+ ): string | undefined {
85
+ let currentEntryId = entryId;
86
+ while (currentEntryId) {
87
+ const tree = checkpoints.get(currentEntryId);
88
+ if (tree) return tree;
89
+ currentEntryId = ctx.sessionManager.getEntry(currentEntryId)?.parentId ?? null;
90
+ }
91
+
92
+ return checkpoints.get(BASE_CHECKPOINT_KEY);
93
+ }
94
+
95
+ export function shouldOfferRestore(
96
+ targetEntryId: string,
97
+ currentEntryId: string | null,
98
+ ctx: ExtensionContext,
99
+ checkpoints: Map<string, string>,
100
+ ): { shouldRestore: boolean; tree?: string } {
101
+ const targetTree = findCheckpoint(targetEntryId, ctx, checkpoints);
102
+ if (!targetTree) return { shouldRestore: false };
103
+
104
+ const currentTree = findCheckpoint(currentEntryId, ctx, checkpoints);
105
+ if (currentTree === targetTree) return { shouldRestore: false };
106
+
107
+ return { shouldRestore: true, tree: targetTree };
108
+ }
@@ -0,0 +1,31 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { getCheckpointRepo, runCheckpointGit } from "../checkpoint/git.js";
5
+ import { pruneCheckpointStore } from "../checkpoint/store.js";
6
+
7
+ export async function cleanupCheckpoints(
8
+ pi: ExtensionAPI,
9
+ cwd: string,
10
+ sessionIds: Set<string>,
11
+ ) {
12
+ const repo = await getCheckpointRepo(pi, cwd);
13
+ if (!repo) return;
14
+
15
+ const trees = await pruneCheckpointStore(join(repo.directory, "entries.json"), sessionIds);
16
+ if (trees.length === 0) return;
17
+
18
+ try {
19
+ await access(join(repo.directory, ".git"));
20
+ } catch {
21
+ return;
22
+ }
23
+
24
+ for (const tree of trees) {
25
+ await runCheckpointGit(pi, repo, [
26
+ "update-ref",
27
+ "-d",
28
+ `refs/surgent/checkpoints/${tree}`,
29
+ ]);
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import { isUuidv7, readJson, writeJson } from "../utils.js";
2
+
3
+ export function pruneSessionMap(
4
+ data: { [sessionId: string]: unknown },
5
+ sessionIds: Set<string>,
6
+ ): boolean {
7
+ let hasChanges = false;
8
+ for (const sessionId of Object.keys(data)) {
9
+ if (!isUuidv7(sessionId) || sessionIds.has(sessionId)) {
10
+ continue;
11
+ }
12
+ delete data[sessionId];
13
+ hasChanges = true;
14
+ }
15
+ return hasChanges;
16
+ }
17
+
18
+ export async function pruneSessionFile(filePath: string, sessionIds: Set<string>) {
19
+ const data = await readJson<{ [sessionId: string]: unknown }>(filePath, {});
20
+ const hasChanges = pruneSessionMap(data, sessionIds);
21
+ if (hasChanges) {
22
+ await writeJson(filePath, data);
23
+ }
24
+ }
@@ -0,0 +1,21 @@
1
+ import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { cleanupCheckpoints } from "./checkpoint.js";
3
+ import { cleanupPermissions } from "./permission.js";
4
+ import { cleanupSubsessions } from "./subsession.js";
5
+ import { getPiPath } from "../utils.js";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ pi.on("session_start", async (_event, ctx) => {
9
+ const [sessions, subsessions] = await Promise.all([
10
+ SessionManager.list(ctx.cwd),
11
+ SessionManager.list(ctx.cwd, getPiPath("subsessionsDir", ctx.cwd)),
12
+ ]);
13
+ const sessionIds = new Set(sessions.map((session) => session.id));
14
+ const allSessionIds = new Set([...sessionIds, ...subsessions.map((session) => session.id)]);
15
+ sessionIds.add(ctx.sessionManager.getSessionId());
16
+
17
+ cleanupCheckpoints(pi, ctx.cwd, allSessionIds).catch(() => undefined);
18
+ cleanupPermissions(ctx.cwd, allSessionIds).catch(() => undefined);
19
+ cleanupSubsessions(ctx.cwd, sessionIds).catch(() => undefined);
20
+ });
21
+ }
@@ -0,0 +1,74 @@
1
+ import { matchesPattern } from "../permission/precedence.js";
2
+ import { readRules, writeRules } from "../permission/storage.js";
3
+ import type { FileAccess, PermissionRule } from "../permission/types.js";
4
+ import { pruneSessionMap } from "./helpers.js";
5
+
6
+ function pruneRules<T extends FileAccess | boolean>(
7
+ rules: Record<string, T>,
8
+ bash = false,
9
+ ): Record<string, T> {
10
+ const patterns = Object.keys(rules);
11
+ const result: Record<string, T> = {};
12
+
13
+ for (const pattern of patterns) {
14
+ const value = rules[pattern]!;
15
+ const isSubsumed = patterns.some((other) => {
16
+ if (other === pattern) return false;
17
+ if (rules[other] !== value) return false;
18
+ // other subsumes pattern if other matches pattern but pattern does not match other
19
+ return matchesPattern(pattern, other, bash) && !matchesPattern(other, pattern, bash);
20
+ });
21
+ if (!isSubsumed) {
22
+ result[pattern] = value;
23
+ }
24
+ }
25
+
26
+ return result;
27
+ }
28
+
29
+ function pruneSchema(schema: PermissionRule): PermissionRule {
30
+ const result: PermissionRule = {};
31
+ if (schema.file) {
32
+ const pruned = pruneRules(schema.file);
33
+ if (Object.keys(pruned).length > 0) result.file = pruned;
34
+ }
35
+ if (schema.web) {
36
+ const pruned = pruneRules(schema.web);
37
+ if (Object.keys(pruned).length > 0) result.web = pruned;
38
+ }
39
+ if (schema.bash) {
40
+ const pruned = pruneRules(schema.bash, true);
41
+ if (Object.keys(pruned).length > 0) result.bash = pruned;
42
+ }
43
+ if (schema.mcp) {
44
+ const pruned = pruneRules(schema.mcp);
45
+ if (Object.keys(pruned).length > 0) result.mcp = pruned;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ export async function cleanupPermissions(cwd: string, sessionIds: Set<string>) {
51
+ cleanupLocal(cwd, sessionIds);
52
+ cleanupGlobal();
53
+ }
54
+
55
+ async function cleanupLocal(cwd: string, sessionIds: Set<string>) {
56
+ const local = await readRules(cwd);
57
+ let changed = pruneSessionMap(local, sessionIds);
58
+
59
+ if (local.project) {
60
+ const pruned = pruneSchema(local.project);
61
+ local.project = pruned;
62
+ changed = true;
63
+ }
64
+
65
+ if (changed) {
66
+ await writeRules(local, cwd);
67
+ }
68
+ }
69
+
70
+ async function cleanupGlobal() {
71
+ const global = await readRules();
72
+ const pruned = pruneSchema(global);
73
+ await writeRules(pruned);
74
+ }