pi-var 1.0.1
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/.github/FUNDING.yml +4 -0
- package/.prettierrc +7 -0
- package/README.md +240 -0
- package/commitlint.config.cjs +3 -0
- package/extensions/pi-var/__tests__/integration/smoke.test.ts +308 -0
- package/extensions/pi-var/__tests__/unit/command.test.ts +322 -0
- package/extensions/pi-var/__tests__/unit/cow-detection.test.ts +340 -0
- package/extensions/pi-var/__tests__/unit/cow-git-merge.test.ts +404 -0
- package/extensions/pi-var/__tests__/unit/edr.test.ts +119 -0
- package/extensions/pi-var/__tests__/unit/environment.test.ts +369 -0
- package/extensions/pi-var/__tests__/unit/file-redirect.test.ts +295 -0
- package/extensions/pi-var/__tests__/unit/store.test.ts +489 -0
- package/extensions/pi-var/__tests__/unit/tools.test.ts +133 -0
- package/extensions/pi-var/__tests__/unit/variations.test.ts +352 -0
- package/extensions/pi-var/index.ts +111 -0
- package/extensions/pi-var/src/config.ts +30 -0
- package/extensions/pi-var/src/cow/clone.ts +46 -0
- package/extensions/pi-var/src/cow/detection.ts +215 -0
- package/extensions/pi-var/src/cow/index.ts +6 -0
- package/extensions/pi-var/src/edr/constants.ts +199 -0
- package/extensions/pi-var/src/edr/crowdstrike.ts +62 -0
- package/extensions/pi-var/src/edr/detection.ts +97 -0
- package/extensions/pi-var/src/edr/index.ts +6 -0
- package/extensions/pi-var/src/environment/copy-env.ts +79 -0
- package/extensions/pi-var/src/environment/detect.ts +91 -0
- package/extensions/pi-var/src/environment/index.ts +54 -0
- package/extensions/pi-var/src/environment/marker.ts +42 -0
- package/extensions/pi-var/src/environment/symlink.ts +82 -0
- package/extensions/pi-var/src/gitignore/manager.ts +77 -0
- package/extensions/pi-var/src/state/index.ts +7 -0
- package/extensions/pi-var/src/state/keys.ts +14 -0
- package/extensions/pi-var/src/state/store.ts +110 -0
- package/extensions/pi-var/src/state/types.ts +70 -0
- package/extensions/pi-var/src/tools/command/clean.ts +87 -0
- package/extensions/pi-var/src/tools/command/index.ts +95 -0
- package/extensions/pi-var/src/tools/command/status.ts +58 -0
- package/extensions/pi-var/src/tools/command/stop.ts +37 -0
- package/extensions/pi-var/src/tools/file-redirect.ts +126 -0
- package/extensions/pi-var/src/tools/redirect/bash.ts +75 -0
- package/extensions/pi-var/src/tools/redirect/edit.ts +60 -0
- package/extensions/pi-var/src/tools/redirect/index.ts +64 -0
- package/extensions/pi-var/src/tools/redirect/read.ts +61 -0
- package/extensions/pi-var/src/tools/redirect/write.ts +60 -0
- package/extensions/pi-var/src/tools/variation/create.ts +108 -0
- package/extensions/pi-var/src/tools/variation/merge.ts +80 -0
- package/extensions/pi-var/src/types/index.ts +139 -0
- package/extensions/pi-var/src/utils/names.ts +120 -0
- package/extensions/pi-var/src/variation/create-copy.ts +35 -0
- package/extensions/pi-var/src/variation/create-cow.ts +81 -0
- package/extensions/pi-var/src/variation/create-worktree.ts +31 -0
- package/extensions/pi-var/src/variation/create.ts +127 -0
- package/extensions/pi-var/src/variation/index.ts +9 -0
- package/extensions/pi-var/src/variation/merge/copy.ts +151 -0
- package/extensions/pi-var/src/variation/merge/git.ts +176 -0
- package/extensions/pi-var/src/variation/merge/index.ts +36 -0
- package/extensions/pi-var/src/variation/merge/rsync.ts +74 -0
- package/extensions/pi-var/src/variation/merge/strategy.ts +62 -0
- package/extensions/pi-var/src/variation/paths.ts +26 -0
- package/extensions/pi-var/src/variation/remove.ts +79 -0
- package/npm-shrinkwrap.json +6908 -0
- package/package.json +63 -0
- package/skills/variations/SKILL.md +182 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variation context detection from current working directory
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import type { VariationContext } from '../types/index';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect variation context from current working directory
|
|
11
|
+
* Returns information about whether we're in a variation and which one
|
|
12
|
+
*/
|
|
13
|
+
export function detectVariationContext(cwd: string): VariationContext {
|
|
14
|
+
// Normalize path
|
|
15
|
+
const normalizedCwd = path.resolve(cwd);
|
|
16
|
+
|
|
17
|
+
// Check if we're inside a .pi/variations directory structure
|
|
18
|
+
const variationsPattern = /[\/\\]\.pi[\/\\]variations[\/\\][^\/\\]+[\/\\]([^\/\\]+)[\/\\]?/;
|
|
19
|
+
const match = normalizedCwd.match(variationsPattern);
|
|
20
|
+
|
|
21
|
+
if (match) {
|
|
22
|
+
const variationName = match[1];
|
|
23
|
+
const variationPath = normalizedCwd.substring(
|
|
24
|
+
0,
|
|
25
|
+
normalizedCwd.indexOf(match[0]) + match[0].length
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Find source path (go up to project root)
|
|
29
|
+
const sourcePath = path.dirname(path.dirname(path.dirname(path.dirname(variationPath))));
|
|
30
|
+
|
|
31
|
+
// Generate a consistent variation ID based on path
|
|
32
|
+
const variationId = `var-${Buffer.from(variationPath)
|
|
33
|
+
.toString('base64')
|
|
34
|
+
.replace(/[^a-zA-Z0-9]/g, '')
|
|
35
|
+
.slice(0, 16)}`;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
inVariation: true,
|
|
39
|
+
variationId,
|
|
40
|
+
variationName,
|
|
41
|
+
variationPath,
|
|
42
|
+
sourcePath,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Not in a variation - return context with cwd as source
|
|
47
|
+
return {
|
|
48
|
+
inVariation: false,
|
|
49
|
+
variationId: null,
|
|
50
|
+
variationName: null,
|
|
51
|
+
variationPath: null,
|
|
52
|
+
sourcePath: normalizedCwd,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect variation context async - checks for marker file
|
|
58
|
+
* Use this when you need to detect via marker file
|
|
59
|
+
*/
|
|
60
|
+
export async function detectVariationContextAsync(cwd: string): Promise<VariationContext> {
|
|
61
|
+
// First try the sync version
|
|
62
|
+
const syncContext = detectVariationContext(cwd);
|
|
63
|
+
if (syncContext.inVariation) {
|
|
64
|
+
return syncContext;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check for .pi-variation marker file
|
|
68
|
+
const normalizedCwd = path.resolve(cwd);
|
|
69
|
+
const markerPath = path.join(normalizedCwd, '.pi-variation');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const stat = await fs.stat(markerPath);
|
|
73
|
+
if (stat.isFile()) {
|
|
74
|
+
const content = await fs.readFile(markerPath, 'utf-8');
|
|
75
|
+
const info = JSON.parse(content);
|
|
76
|
+
if (info.sourcePath && info.variationName) {
|
|
77
|
+
return {
|
|
78
|
+
inVariation: true,
|
|
79
|
+
variationId: info.id || 'var-unknown',
|
|
80
|
+
variationName: info.variationName,
|
|
81
|
+
variationPath: normalizedCwd,
|
|
82
|
+
sourcePath: info.sourcePath,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// No marker file or error reading it, ignore
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return syncContext;
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment setup and variation context detection module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { VarConfig } from '../types/index';
|
|
6
|
+
import { copyEnvFiles } from './copy-env';
|
|
7
|
+
import { symlinkHeavyDirs } from './symlink';
|
|
8
|
+
import { detectVariationContext, detectVariationContextAsync } from './detect';
|
|
9
|
+
import { createVariationMarker, removeVariationMarker } from './marker';
|
|
10
|
+
|
|
11
|
+
// Default configuration for environment sync
|
|
12
|
+
export const DEFAULT_CONFIG = {
|
|
13
|
+
copy: ['.env', '.env.*', '.envrc', '.npmrc', '.tool-versions'],
|
|
14
|
+
symlink: ['node_modules', '.next', '.nuxt', 'target', '.venv'],
|
|
15
|
+
postCreate: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Setup the variation environment by copying env files and symlinking heavy directories
|
|
20
|
+
*/
|
|
21
|
+
export async function setupVariationEnvironment(
|
|
22
|
+
source: string,
|
|
23
|
+
variation: string,
|
|
24
|
+
config: Partial<VarConfig> = {}
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
27
|
+
|
|
28
|
+
// Copy environment files
|
|
29
|
+
await copyEnvFiles(source, variation, mergedConfig.copy);
|
|
30
|
+
|
|
31
|
+
// Symlink heavy directories
|
|
32
|
+
await symlinkHeavyDirs(source, variation, mergedConfig.symlink);
|
|
33
|
+
|
|
34
|
+
// Run post-create commands
|
|
35
|
+
for (const cmd of mergedConfig.postCreate) {
|
|
36
|
+
try {
|
|
37
|
+
const { exec } = await import('child_process');
|
|
38
|
+
const { promisify } = await import('util');
|
|
39
|
+
const execAsync = promisify(exec);
|
|
40
|
+
await execAsync(cmd, { cwd: variation });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn(`Post-create command failed: ${cmd}`, err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
detectVariationContext,
|
|
49
|
+
detectVariationContextAsync,
|
|
50
|
+
createVariationMarker,
|
|
51
|
+
removeVariationMarker,
|
|
52
|
+
copyEnvFiles,
|
|
53
|
+
symlinkHeavyDirs,
|
|
54
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variation marker file management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a marker file in the variation for context detection
|
|
10
|
+
*/
|
|
11
|
+
export async function createVariationMarker(
|
|
12
|
+
variationPath: string,
|
|
13
|
+
info: {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
sourcePath: string;
|
|
17
|
+
type: string;
|
|
18
|
+
}
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const markerPath = path.join(variationPath, '.pi-variation');
|
|
21
|
+
const content = {
|
|
22
|
+
id: info.id,
|
|
23
|
+
variationName: info.name,
|
|
24
|
+
sourcePath: info.sourcePath,
|
|
25
|
+
type: info.type,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await fs.writeFile(markerPath, JSON.stringify(content, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove the variation marker file
|
|
34
|
+
*/
|
|
35
|
+
export async function removeVariationMarker(variationPath: string): Promise<void> {
|
|
36
|
+
const markerPath = path.join(variationPath, '.pi-variation');
|
|
37
|
+
try {
|
|
38
|
+
await fs.unlink(markerPath);
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore if doesn't exist
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symlink management for heavy directories
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create symlinks for heavy directories (node_modules, .next, etc.)
|
|
10
|
+
*/
|
|
11
|
+
export async function symlinkHeavyDirs(
|
|
12
|
+
source: string,
|
|
13
|
+
variation: string,
|
|
14
|
+
dirNames: string[]
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
for (const dirName of dirNames) {
|
|
17
|
+
const srcPath = path.join(source, dirName);
|
|
18
|
+
const destPath = path.join(variation, dirName);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Check if source directory exists
|
|
22
|
+
const stat = await fs.stat(srcPath);
|
|
23
|
+
if (!stat.isDirectory()) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Ensure the variation parent directory exists
|
|
28
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
29
|
+
|
|
30
|
+
// Remove destination if it exists (but is not a symlink)
|
|
31
|
+
try {
|
|
32
|
+
const destStat = await fs.lstat(destPath);
|
|
33
|
+
if (destStat.isSymbolicLink()) {
|
|
34
|
+
// Already a symlink, might be from previous setup
|
|
35
|
+
await fs.unlink(destPath);
|
|
36
|
+
} else if (destStat.isDirectory()) {
|
|
37
|
+
// It's a directory - remove it to replace with symlink
|
|
38
|
+
await fs.rm(destPath, { recursive: true });
|
|
39
|
+
} else {
|
|
40
|
+
// It's a file
|
|
41
|
+
await fs.unlink(destPath);
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
// Destination doesn't exist, proceed
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Create the symlink
|
|
51
|
+
// Use relative path for portability
|
|
52
|
+
const relativeSrc = path.relative(path.dirname(destPath), srcPath);
|
|
53
|
+
await fs.symlink(relativeSrc, destPath, 'junction');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
56
|
+
// Source directory doesn't exist, skip
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Log but don't throw - symlinks are optional optimizations
|
|
60
|
+
console.warn(`Failed to symlink ${dirName}:`, (err as Error).message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove symlinks from a variation (before merge or delete)
|
|
67
|
+
* @internal Not currently used but kept for future cleanup operations
|
|
68
|
+
*/
|
|
69
|
+
async function removeSymlinks(variation: string): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
const entries = await fs.readdir(variation, { withFileTypes: true });
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.isSymbolicLink()) {
|
|
75
|
+
const linkPath = path.join(variation, entry.name);
|
|
76
|
+
await fs.unlink(linkPath);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Ignore errors during cleanup
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global gitignore management for .pi/variations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs, constants as fsConstants } from 'fs';
|
|
6
|
+
import * as fsSync from 'fs';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
|
|
11
|
+
/** Get the path to the global gitignore file */
|
|
12
|
+
function getGlobalGitignorePath(): string | null {
|
|
13
|
+
try {
|
|
14
|
+
// Check if core.excludesfile is set
|
|
15
|
+
const result = execSync('git config --global core.excludesfile', {
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
18
|
+
});
|
|
19
|
+
const configured = result.trim();
|
|
20
|
+
if (configured) return configured;
|
|
21
|
+
} catch {
|
|
22
|
+
// Not configured, fall through to default
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Default locations by platform
|
|
26
|
+
// Check env vars first (allows testing with fake home), then os.homedir()
|
|
27
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
28
|
+
const candidates = [
|
|
29
|
+
path.join(home, '.gitignore'),
|
|
30
|
+
path.join(home, '.gitignore_global'),
|
|
31
|
+
path.join(home, '.config', 'git', 'ignore'),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
if (fsSync.existsSync(candidate)) {
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default to ~/.gitignore if nothing exists
|
|
41
|
+
return path.join(home, '.gitignore');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ensure .pi/variations/ is in the global gitignore */
|
|
45
|
+
export function ensureGlobalGitignore(): void {
|
|
46
|
+
try {
|
|
47
|
+
const gitignorePath = getGlobalGitignorePath();
|
|
48
|
+
if (!gitignorePath) return;
|
|
49
|
+
|
|
50
|
+
const pattern = '.pi/variations/';
|
|
51
|
+
let content = '';
|
|
52
|
+
|
|
53
|
+
if (fsSync.existsSync(gitignorePath)) {
|
|
54
|
+
content = fsSync.readFileSync(gitignorePath, 'utf-8');
|
|
55
|
+
// Already present?
|
|
56
|
+
if (content.split(/\r?\n/).some((line) => line.trim() === pattern)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Ensure parent directory exists
|
|
62
|
+
const parentDir = path.dirname(gitignorePath);
|
|
63
|
+
if (!fsSync.existsSync(parentDir)) {
|
|
64
|
+
fsSync.mkdirSync(parentDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Append with a comment
|
|
68
|
+
const entry =
|
|
69
|
+
content.endsWith('\n') || content === ''
|
|
70
|
+
? `# pi-var variations\n${pattern}\n`
|
|
71
|
+
: `\n# pi-var variations\n${pattern}\n`;
|
|
72
|
+
|
|
73
|
+
fsSync.appendFileSync(gitignorePath, entry, 'utf-8');
|
|
74
|
+
} catch {
|
|
75
|
+
// Silently fail — this is a convenience, not a requirement
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session key extraction
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract session ID from extension context
|
|
9
|
+
* @param ctx - Extension context containing session manager
|
|
10
|
+
* @returns Session key string
|
|
11
|
+
*/
|
|
12
|
+
export function getSessionKey(ctx: ExtensionContext): string {
|
|
13
|
+
return ctx.sessionManager.getSessionId?.() || 'default';
|
|
14
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State persistence and restoration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { VAR_STATE_ENTRY_TYPE, PersistedVarState, SessionManager, RuntimeStore } from './types.js';
|
|
7
|
+
import type { VarRuntime, VarState } from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create default initial state for a new session
|
|
11
|
+
* @param sessionId - The session identifier
|
|
12
|
+
* @returns Initial VarState object
|
|
13
|
+
*/
|
|
14
|
+
function createInitialState(sessionId: string): VarState {
|
|
15
|
+
return {
|
|
16
|
+
activeVariationId: null,
|
|
17
|
+
variations: [],
|
|
18
|
+
sessionId,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new VarRuntime instance with initial state
|
|
24
|
+
* @param sessionId - The session identifier
|
|
25
|
+
* @returns New VarRuntime instance
|
|
26
|
+
*/
|
|
27
|
+
function createRuntime(sessionId: string): VarRuntime {
|
|
28
|
+
return {
|
|
29
|
+
state: createInitialState(sessionId),
|
|
30
|
+
lastPersisted: Date.now(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create Map-based runtime store for managing per-session VarRuntime instances
|
|
36
|
+
* @returns RuntimeStore instance with ensure, get, delete, and persistState methods
|
|
37
|
+
*/
|
|
38
|
+
export function createRuntimeStore(): RuntimeStore {
|
|
39
|
+
const store = new Map<string, VarRuntime>();
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ensure(sessionKey: string): VarRuntime {
|
|
43
|
+
let runtime = store.get(sessionKey);
|
|
44
|
+
if (!runtime) {
|
|
45
|
+
runtime = createRuntime(sessionKey);
|
|
46
|
+
store.set(sessionKey, runtime);
|
|
47
|
+
}
|
|
48
|
+
return runtime;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
get(sessionKey: string): VarRuntime | undefined {
|
|
52
|
+
return store.get(sessionKey);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
delete(sessionKey: string): boolean {
|
|
56
|
+
return store.delete(sessionKey);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
persistState(sessionKey: string, pi: ExtensionAPI): void {
|
|
60
|
+
const runtime = store.get(sessionKey);
|
|
61
|
+
if (!runtime) return;
|
|
62
|
+
|
|
63
|
+
const persistedState: PersistedVarState = {
|
|
64
|
+
variations: runtime.state.variations,
|
|
65
|
+
activeVariationId: runtime.state.activeVariationId,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
pi.appendEntry(VAR_STATE_ENTRY_TYPE, persistedState);
|
|
69
|
+
runtime.lastPersisted = Date.now();
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
restoreState(sessionKey: string, sessionManager: SessionManager): void {
|
|
73
|
+
const runtime = store.get(sessionKey);
|
|
74
|
+
if (!runtime) return;
|
|
75
|
+
|
|
76
|
+
// Get entries - prefer getEntries() as it's always available during session_start
|
|
77
|
+
// getBranch() may fail early in session lifecycle before tree is initialized
|
|
78
|
+
let entries: SessionEntry[] = [];
|
|
79
|
+
try {
|
|
80
|
+
if (sessionManager.getEntries) {
|
|
81
|
+
entries = sessionManager.getEntries();
|
|
82
|
+
} else if (sessionManager.getBranch) {
|
|
83
|
+
entries = sessionManager.getBranch();
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Session manager may not be fully initialized yet
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!entries || entries.length === 0) return;
|
|
91
|
+
|
|
92
|
+
// Find the last state entry (entries are in order, so we take the last one)
|
|
93
|
+
const stateEntries = entries.filter(
|
|
94
|
+
(e: SessionEntry) => e.type === 'custom' && e.customType === VAR_STATE_ENTRY_TYPE
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (stateEntries.length === 0) return;
|
|
98
|
+
|
|
99
|
+
const lastEntry = stateEntries[stateEntries.length - 1];
|
|
100
|
+
const persistedState = lastEntry.data as PersistedVarState;
|
|
101
|
+
|
|
102
|
+
if (persistedState) {
|
|
103
|
+
runtime.state.variations = persistedState.variations || [];
|
|
104
|
+
runtime.state.activeVariationId = persistedState.activeVariationId ?? null;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
import type { SessionEntry } from './types.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State types and interfaces
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { VarRuntime, VarState, Variation } from '../types/index';
|
|
6
|
+
|
|
7
|
+
/** Custom entry type for session persistence */
|
|
8
|
+
export const VAR_STATE_ENTRY_TYPE = 'pi-var:state';
|
|
9
|
+
|
|
10
|
+
/** Serialized state for persistence */
|
|
11
|
+
export interface PersistedVarState {
|
|
12
|
+
variations: Variation[];
|
|
13
|
+
activeVariationId: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Session entry interface for state restoration */
|
|
17
|
+
export interface SessionEntry {
|
|
18
|
+
type: string;
|
|
19
|
+
customType?: string;
|
|
20
|
+
data?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Session manager interface for accessing entries */
|
|
24
|
+
export interface SessionManager {
|
|
25
|
+
getEntries?(): SessionEntry[];
|
|
26
|
+
getBranch?(): SessionEntry[];
|
|
27
|
+
getSessionId?(): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extension context interface for session key extraction
|
|
32
|
+
*/
|
|
33
|
+
export interface ExtensionContext {
|
|
34
|
+
sessionManager: SessionManager;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runtime store interface for managing per-session VarRuntime instances
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export interface RuntimeStore {
|
|
42
|
+
/**
|
|
43
|
+
* Ensure a VarRuntime exists for the given session key,
|
|
44
|
+
* creating one if it doesn't exist
|
|
45
|
+
*/
|
|
46
|
+
ensure(sessionKey: string): VarRuntime;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get an existing VarRuntime for the session key,
|
|
50
|
+
* returning undefined if not found
|
|
51
|
+
*/
|
|
52
|
+
get(sessionKey: string): VarRuntime | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Delete a VarRuntime for the given session key
|
|
56
|
+
*/
|
|
57
|
+
delete(sessionKey: string): boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Persist current state to the session using pi.appendEntry()
|
|
61
|
+
*/
|
|
62
|
+
persistState(sessionKey: string, pi: ExtensionAPI): void;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Restore state from session entries
|
|
66
|
+
*/
|
|
67
|
+
restoreState(sessionKey: string, sessionManager: SessionManager): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /var command clean handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { VarRuntime } from '../../types/index';
|
|
7
|
+
import { removeVariation } from '../../variation/remove';
|
|
8
|
+
import type { RuntimeStore } from '../../state/types';
|
|
9
|
+
|
|
10
|
+
function getSessionKey(ctx: ExtensionContext): string {
|
|
11
|
+
return ctx.sessionManager.getSessionId?.() || 'default';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleCleanStale(
|
|
15
|
+
runtime: VarRuntime,
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
staleDays: number,
|
|
18
|
+
runtimeStore: RuntimeStore,
|
|
19
|
+
pi: ExtensionAPI
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const cutoff = Date.now() - staleDays * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
const staleVariations = runtime.state.variations.filter((v) => {
|
|
24
|
+
const lastAccessed = new Date(v.lastAccessed).getTime();
|
|
25
|
+
return lastAccessed < cutoff;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (staleVariations.length === 0) {
|
|
29
|
+
ctx.ui.notify(`No variations older than ${staleDays} days`, 'info');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const confirmed = await ctx.ui.confirm(
|
|
34
|
+
'Clean stale variations?',
|
|
35
|
+
`Delete ${staleVariations.length} variation(s) older than ${staleDays} days?`
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (!confirmed) return;
|
|
39
|
+
|
|
40
|
+
for (const v of staleVariations) {
|
|
41
|
+
await removeVariation(v);
|
|
42
|
+
runtime.state.variations = runtime.state.variations.filter((existing) => existing.id !== v.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Persist state after cleaning
|
|
46
|
+
runtimeStore.persistState(getSessionKey(ctx), pi);
|
|
47
|
+
|
|
48
|
+
ctx.ui.notify(`Cleaned ${staleVariations.length} stale variation(s)`, 'info');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function handleCleanSpecific(
|
|
52
|
+
runtime: VarRuntime,
|
|
53
|
+
ctx: ExtensionContext,
|
|
54
|
+
name: string,
|
|
55
|
+
runtimeStore: RuntimeStore,
|
|
56
|
+
pi: ExtensionAPI
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const variation = runtime.state.variations.find((v) => v.name === name);
|
|
59
|
+
if (!variation) {
|
|
60
|
+
ctx.ui.notify(`Variation "${name}" not found`, 'error');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const confirmed = await ctx.ui.confirm(
|
|
65
|
+
'Delete variation?',
|
|
66
|
+
`Delete "${name}"? This cannot be undone.`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!confirmed) {
|
|
70
|
+
ctx.ui.notify('Clean cancelled', 'info');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await removeVariation(variation);
|
|
75
|
+
runtime.state.variations = runtime.state.variations.filter((v) => v.id !== variation.id);
|
|
76
|
+
|
|
77
|
+
// If this was active, deactivate
|
|
78
|
+
if (runtime.state.activeVariationId === variation.id) {
|
|
79
|
+
runtime.state.activeVariationId = null;
|
|
80
|
+
ctx.ui.setStatus('pi-var', '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Persist state after cleaning
|
|
84
|
+
runtimeStore.persistState(getSessionKey(ctx), pi);
|
|
85
|
+
|
|
86
|
+
ctx.ui.notify(`Deleted variation "${name}"`, 'info');
|
|
87
|
+
}
|