@selesai/code 0.13.1 → 0.13.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.2] - 2026-08-30
6
+
7
+ ### Added
8
+ - **Rewind submodule support.** `pi-rewind-hook` now snapshots gitlink commits and restores submodule worktrees to their target commits during exact rewind, with safety checks: submodule paths must stay unchanged, worktrees must be clean and initialized, and target commits must be available locally. Unsupported submodule states (dirty, uninitialized, added/removed paths, nested submodules) are refused with a clear error instead of being reported as exact restores.
9
+ - **Rewind retention sweep concurrency safety.** The retention sweep now uses a compare-and-swap on the store ref with retries, so concurrent sessions adding snapshots mid-sweep are preserved instead of being clobbered; the startup sweep is reused across session replacement instead of racing a second sweep.
10
+
11
+ ### Changed
12
+ - **Herdr status bridge drops agent scoping.** `pi-subagents` no longer passes `--agent pi` / `--applies-to-source herdr:pi` to Herdr `report-metadata` calls; state labels are reported under the `pi-subagents:herdr` source directly.
13
+ - **Rewind extension packaging.** `pi-rewind-hook` declares `@selesai/code` as a peer dependency (was devDependency) and bumps to 1.8.6.
14
+
5
15
  ## [0.13.1] - 2026-08-30
6
16
 
7
17
  ### Fixed
@@ -12,7 +12,7 @@ Rewind metadata lives in the session itself as hidden entries, so rewind history
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Pi agent v0.65.0+
15
+ - Pi agent v0.74.0+
16
16
  - Node.js (for installation)
17
17
  - Git repository
18
18
 
@@ -181,6 +181,8 @@ Rewind restores the exact file state for the snapshot domain it owns:
181
181
 
182
182
  Before restoring target contents, it deletes paths present in the current snapshot but absent from the target snapshot, then restores the target snapshot into the worktree only.
183
183
 
184
+ Submodules are supported only when their paths stay unchanged, their worktrees are clean and initialized, and each target commit is already available locally. Rewind checks out those target commits without fetching. Exact restore is refused with a clear error for dirty or unavailable submodule state, added or removed submodule paths, and nested submodules; uncommitted files inside submodules are not part of snapshots.
185
+
184
186
  Out of scope:
185
187
 
186
188
  - ignored files
@@ -240,6 +242,7 @@ grep '"customType":"rewind-' ~/.pi/agent/sessions/**/*.jsonl
240
242
  ## Limitations
241
243
 
242
244
  - Only works in git repositories
245
+ - Submodule snapshots contain gitlink commits, not uncommitted nested files; unsupported submodule states are refused instead of reported as exact restores
243
246
  - Session metadata grows append-only; retention only trims git reachability
244
247
  - Discovery for retention is best-effort across discovered Pi session roots and explicit `parentSession` ancestors
245
248
  - Ignored files and empty directories are outside the snapshot model
@@ -17,6 +17,8 @@ const RETENTION_SWEEP_THRESHOLD = 50;
17
17
  const RETENTION_VERSION = 2;
18
18
  const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
19
19
 
20
+ class UnsupportedSubmoduleStateError extends Error {}
21
+
20
22
  type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
21
23
 
22
24
  type GitExecResult = Awaited<ReturnType<ExecFn>>;
@@ -70,6 +72,11 @@ interface ExactState {
70
72
  treeSha: string;
71
73
  }
72
74
 
75
+ interface GitlinkEntry {
76
+ path: string;
77
+ commitSha: string;
78
+ }
79
+
73
80
  interface ActiveBranchState {
74
81
  currentCommitSha?: string;
75
82
  currentTreeSha?: string;
@@ -387,7 +394,7 @@ export default function rewindExtension(pi: ExtensionAPI) {
387
394
  let pendingTreeState: PendingResultingState | null = null;
388
395
  let activePromptText: string | null = null;
389
396
  let newSnapshotsSinceSweep = 0;
390
- let sweepRunning = false;
397
+ let retentionSweep: Promise<void> | undefined;
391
398
  let sweepCompletedThisSession = false;
392
399
  let forceConversationOnlyOnNextFork = false;
393
400
  let forceConversationOnlySource: string | null = null;
@@ -461,6 +468,87 @@ export default function rewindExtension(pi: ExtensionAPI) {
461
468
  return repoRoot;
462
469
  }
463
470
 
471
+ async function getGitlinkEntries(treeSha: string, gitPrefix: string[] = []): Promise<GitlinkEntry[]> {
472
+ const result = await execGitChecked([...gitPrefix, "ls-tree", "-r", "-z", treeSha, "--"]);
473
+ return parseGitlinkEntries(result.stdout);
474
+ }
475
+
476
+ function parseGitlinkEntries(output: string): GitlinkEntry[] {
477
+ return output.split("\0").flatMap((record) => {
478
+ const separator = record.indexOf("\t");
479
+ if (separator < 0) return [];
480
+
481
+ const [mode, type, commitSha] = record.slice(0, separator).split(/\s+/);
482
+ if (mode !== "160000" || type !== "commit" || !commitSha) return [];
483
+ return [{ path: record.slice(separator + 1), commitSha }];
484
+ });
485
+ }
486
+
487
+ async function getIndexedGitlinkEntries(): Promise<GitlinkEntry[]> {
488
+ const result = await execGitChecked(["ls-files", "--stage", "-z"]);
489
+ return parseGitlinkEntries(result.stdout);
490
+ }
491
+
492
+ async function assertSubmoduleWorktreesClean(root: string, entries: GitlinkEntry[], action: string) {
493
+ for (const entry of entries) {
494
+ const submodulePath = resolve(root, entry.path);
495
+ if (!isInsidePath(submodulePath, root)) {
496
+ throw new UnsupportedSubmoduleStateError(`refusing ${action} for submodule path outside repo root: ${entry.path}`);
497
+ }
498
+
499
+ const status = await pi.exec("git", ["-C", submodulePath, "status", "--porcelain", "--untracked-files=all"]);
500
+ if (status.code !== 0) {
501
+ throw new UnsupportedSubmoduleStateError(`cannot ${action} with uninitialized submodule: ${entry.path}`);
502
+ }
503
+ if (status.stdout.trim()) {
504
+ throw new UnsupportedSubmoduleStateError(`cannot ${action} with dirty submodule: ${entry.path}`);
505
+ }
506
+ }
507
+ }
508
+
509
+ async function prepareSubmoduleRestore(root: string, currentEntries: GitlinkEntry[], targetEntries: GitlinkEntry[]) {
510
+ const currentPaths = new Set(currentEntries.map((entry) => entry.path));
511
+ const targetPaths = new Set(targetEntries.map((entry) => entry.path));
512
+ const changedPaths = [...new Set([...currentPaths, ...targetPaths])]
513
+ .filter((entryPath) => !currentPaths.has(entryPath) || !targetPaths.has(entryPath));
514
+ if (changedPaths.length > 0) {
515
+ throw new UnsupportedSubmoduleStateError(
516
+ `exact rewind does not support adding or removing submodules: ${changedPaths.join(", ")}`,
517
+ );
518
+ }
519
+
520
+ await assertSubmoduleWorktreesClean(root, currentEntries, "restore exact files");
521
+
522
+ const commitsToInspect = new Map<string, GitlinkEntry>();
523
+ for (const entry of [...currentEntries, ...targetEntries]) {
524
+ commitsToInspect.set(`${entry.path}\0${entry.commitSha}`, entry);
525
+ }
526
+
527
+ for (const entry of commitsToInspect.values()) {
528
+ const entryPath = entry.path;
529
+ const commitSha = entry.commitSha;
530
+ const submodulePath = resolve(root, entryPath);
531
+ const commit = await pi.exec("git", ["-C", submodulePath, "cat-file", "-e", `${commitSha}^{commit}`]);
532
+ if (commit.code !== 0) {
533
+ throw new UnsupportedSubmoduleStateError(
534
+ `cannot inspect submodule ${entryPath}: commit ${commitSha.slice(0, 12)} is unavailable locally`,
535
+ );
536
+ }
537
+
538
+ const targetTreeSha = (await execGitChecked(["-C", submodulePath, "show", "-s", "--format=%T", commitSha])).stdout.trim();
539
+ const nestedEntries = await getGitlinkEntries(targetTreeSha, ["-C", submodulePath]);
540
+ if (nestedEntries.length > 0) {
541
+ throw new UnsupportedSubmoduleStateError(`cannot restore nested submodules: ${entryPath}`);
542
+ }
543
+ }
544
+ }
545
+
546
+ async function restoreSubmodules(root: string, entries: GitlinkEntry[]) {
547
+ for (const entry of entries) {
548
+ await execGitChecked(["-C", resolve(root, entry.path), "checkout", "--detach", entry.commitSha]);
549
+ }
550
+ }
551
+
464
552
  async function captureWorktreeTree(): Promise<{ treeSha: string }> {
465
553
  const root = await getRepoRoot(pi.exec);
466
554
  const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
@@ -470,7 +558,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
470
558
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
471
559
  await execAsync("git add -A", { cwd: root, env });
472
560
  const { stdout } = await execAsync("git write-tree", { cwd: root, env });
473
- return { treeSha: stdout.trim() };
561
+ const treeSha = stdout.trim();
562
+ const entriesByPath = new Map<string, GitlinkEntry>();
563
+ for (const entry of await getIndexedGitlinkEntries()) entriesByPath.set(entry.path, entry);
564
+ for (const entry of await getGitlinkEntries(treeSha)) entriesByPath.set(entry.path, entry);
565
+ await assertSubmoduleWorktreesClean(root, [...entriesByPath.values()], "capture exact files");
566
+ return { treeSha };
474
567
  } finally {
475
568
  await rm(tempDir, { recursive: true, force: true }).catch(() => {
476
569
  // Best effort cleanup for temporary index directory.
@@ -542,19 +635,38 @@ export default function rewindExtension(pi: ExtensionAPI) {
542
635
  return "preserved-empty";
543
636
  }
544
637
 
545
- let head: string | undefined;
546
- for (const commitSha of uniqueLiveCommits) {
547
- head = await createStoreKeepaliveCommit(commitSha, head);
548
- }
638
+ let attempts = 0;
639
+ let lastError: unknown;
640
+ let preserveConcurrentHead = false;
641
+
642
+ while (attempts < 5) {
643
+ attempts += 1;
644
+ const oldHead = await getStoreHead();
645
+ // A failed compare-and-swap means another session added snapshots while
646
+ // this sweep was running. Preserve that new reachability and let a later
647
+ // uncontended sweep prune anything no longer retained.
648
+ let head = preserveConcurrentHead ? oldHead : undefined;
649
+ for (const commitSha of uniqueLiveCommits) {
650
+ head = await createStoreKeepaliveCommit(commitSha, head);
651
+ }
549
652
 
550
- const oldHead = await getStoreHead();
551
- if (oldHead) {
552
- await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
553
- return "rewritten";
653
+ try {
654
+ if (oldHead) {
655
+ await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
656
+ } else {
657
+ await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
658
+ }
659
+ return "rewritten";
660
+ } catch (error) {
661
+ // Retry if another process updated the store ref concurrently.
662
+ // Keep the most recent error for actionable failure context.
663
+ lastError = error;
664
+ preserveConcurrentHead = true;
665
+ }
554
666
  }
555
667
 
556
- await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
557
- return "rewritten";
668
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
669
+ throw new Error(`failed to rewrite rewind store ref: ${detail}`);
558
670
  }
559
671
 
560
672
  async function ensureSnapshotForTree(treeSha: string): Promise<string> {
@@ -606,6 +718,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
606
718
  const { treeSha: currentTreeSha } = await captureWorktreeTree();
607
719
  const targetTreeSha = await getCommitTreeSha(targetCommitSha);
608
720
 
721
+ const currentSubmodules = await getGitlinkEntries(currentTreeSha);
722
+ const targetSubmodules = await getGitlinkEntries(targetTreeSha);
723
+ if (currentSubmodules.length > 0 || targetSubmodules.length > 0) {
724
+ await prepareSubmoduleRestore(repoRoot!, currentSubmodules, targetSubmodules);
725
+ }
726
+
609
727
  if (currentTreeSha === targetTreeSha) {
610
728
  lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
611
729
  return { changed: false, targetTreeSha };
@@ -615,6 +733,13 @@ export default function rewindExtension(pi: ExtensionAPI) {
615
733
  const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha);
616
734
  await deletePathsFromWorkingTree(pathsToDelete);
617
735
  await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]);
736
+ if (targetSubmodules.length > 0) {
737
+ await restoreSubmodules(repoRoot!, targetSubmodules);
738
+ const { treeSha: restoredTreeSha } = await captureWorktreeTree();
739
+ if (restoredTreeSha !== targetTreeSha) {
740
+ throw new Error(`submodule restore did not produce target tree ${targetTreeSha}`);
741
+ }
742
+ }
618
743
  lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
619
744
  return { changed: true, undoCommitSha, targetTreeSha };
620
745
  }
@@ -964,13 +1089,13 @@ export default function rewindExtension(pi: ExtensionAPI) {
964
1089
  if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
965
1090
  if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
966
1091
  if (!repoRoot) return;
967
- if (sweepRunning) return;
968
- sweepRunning = true;
969
- try {
970
- await runRetentionSweep(ctx, reason);
971
- } finally {
972
- sweepRunning = false;
973
- }
1092
+ // Session replacement invalidates ctx after session_shutdown resolves, so reuse
1093
+ // the in-flight startup sweep and let shutdown await it before returning.
1094
+ if (retentionSweep) return retentionSweep;
1095
+ retentionSweep = runRetentionSweep(ctx, reason).finally(() => {
1096
+ retentionSweep = undefined;
1097
+ });
1098
+ return retentionSweep;
974
1099
  }
975
1100
 
976
1101
  async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
@@ -1127,7 +1252,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
1127
1252
  }
1128
1253
 
1129
1254
  await getRepoRoot(pi.exec);
1130
- await reconstructState(ctx);
1255
+ try {
1256
+ await reconstructState(ctx);
1257
+ } catch (error) {
1258
+ if (!(error instanceof UnsupportedSubmoduleStateError)) throw error;
1259
+ notify(ctx, `Rewind unavailable: ${error.message}`, "warning");
1260
+ }
1131
1261
  updateStatus(ctx);
1132
1262
  maybeSweepRetention(ctx, "startup").catch((error) => {
1133
1263
  notify(ctx, `Rewind retention startup sweep failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -1151,7 +1281,7 @@ export default function rewindExtension(pi: ExtensionAPI) {
1151
1281
  const ctx = activeContext;
1152
1282
  if (!ctx) return;
1153
1283
 
1154
- checkpointEntry(ctx, data.entryId).catch((error) => {
1284
+ return checkpointEntry(ctx, data.entryId).catch((error) => {
1155
1285
  notify(ctx, `Rewind: failed to checkpoint ${data.entryId} (${error instanceof Error ? error.message : String(error)})`, "warning");
1156
1286
  });
1157
1287
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Rewind extension for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,14 @@
18
18
  "author": "nicobailon",
19
19
  "license": "MIT",
20
20
  "pi": {
21
- "extensions": ["./index.ts"]
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
22
24
  },
23
25
  "scripts": {
24
26
  "test": "npx --yes tsx --tsconfig tsconfig.test.json --test index.test.ts"
25
27
  },
26
- "devDependencies": {
28
+ "peerDependencies": {
27
29
  "@selesai/code": "*"
28
30
  }
29
31
  }
@@ -205,8 +205,6 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
205
205
  enqueue([
206
206
  "pane", "report-metadata", paneId,
207
207
  "--source", DEFAULT_SOURCE,
208
- "--agent", "pi",
209
- "--applies-to-source", "herdr:pi",
210
208
  "--clear-state-labels",
211
209
  "--clear-token", "summary",
212
210
  "--clear-token", "title-suffix",
@@ -220,8 +218,6 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
220
218
  enqueue([
221
219
  "pane", "report-metadata", paneId,
222
220
  "--source", DEFAULT_SOURCE,
223
- "--agent", "pi",
224
- "--applies-to-source", "herdr:pi",
225
221
  "--state-label", `idle=${text}`,
226
222
  "--state-label", `done=${text}`,
227
223
  "--state-label", `working=${text}`,
@@ -159,17 +159,15 @@ describe("Herdr status bridge", () => {
159
159
  label: "⏳ 1 subagent (worker)",
160
160
  }]);
161
161
  assert.equal(commands.length, 1);
162
- assert.deepEqual(commands[0]?.slice(0, 8), [
162
+ assert.deepEqual(commands[0]?.slice(0, 4), [
163
163
  "pane",
164
164
  "report-metadata",
165
165
  "w1:p1",
166
166
  "--source",
167
- "pi-subagents:herdr",
168
- "--agent",
169
- "pi",
170
- "--applies-to-source",
171
167
  ]);
172
- assert.ok(commands[0]?.includes("herdr:pi"));
168
+ assert.ok(commands[0]?.includes("pi-subagents:herdr"));
169
+ assert.ok(!commands[0]?.includes("--agent"));
170
+ assert.ok(!commands[0]?.includes("--applies-to-source"));
173
171
  assert.ok(commands[0]?.includes("--state-label"));
174
172
  assert.ok(commands[0]?.includes("working=⏳ 1 subagent (worker)"));
175
173
  assert.ok(commands[0]?.includes("idle=⏳ 1 subagent (worker)"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- ____ ____ __ ____ ____ ______ ______
2
- /\ _`\ /\ _`\ /\ \ /\ _`\ /\ _`\ /\ _ \/\__ _\
3
- \ \,\L\_\ \ \L\_\ \ \ \ \ \L\_\ \,\L\_\ \ \ \L\ \/_/\ \/
4
- \/_\__ \\ \ _\L\ \ \ __\ \ _\L\/_\__ \ \ \ __ \ \ \ \
5
- /\ \L\ \ \ \L\ \ \ \L\ \\ \ \L\ \/\ \L\ \ \ \ \/\ \ \_\ \__
6
- \ `\____\ \____/\ \____/ \ \____/\ `\____\ \ \_\ \_\/\_____\
7
- \/_____/\/___/ \/___/ \/___/ \/_____/ \/_/\/_/\/_____/
8
-
9
-