@fixy/core 0.0.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/dist/__tests__/diff-parser.test.d.ts +2 -0
- package/dist/__tests__/diff-parser.test.d.ts.map +1 -0
- package/dist/__tests__/diff-parser.test.js +89 -0
- package/dist/__tests__/diff-parser.test.js.map +1 -0
- package/dist/__tests__/fixy-commands.test.d.ts +2 -0
- package/dist/__tests__/fixy-commands.test.d.ts.map +1 -0
- package/dist/__tests__/fixy-commands.test.js +176 -0
- package/dist/__tests__/fixy-commands.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +66 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +77 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/smoke.test.d.ts +2 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +7 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/__tests__/store.test.d.ts +2 -0
- package/dist/__tests__/store.test.d.ts.map +1 -0
- package/dist/__tests__/store.test.js +121 -0
- package/dist/__tests__/store.test.js.map +1 -0
- package/dist/__tests__/turn.test.d.ts +2 -0
- package/dist/__tests__/turn.test.d.ts.map +1 -0
- package/dist/__tests__/turn.test.js +194 -0
- package/dist/__tests__/turn.test.js.map +1 -0
- package/dist/__tests__/worktree.test.d.ts +2 -0
- package/dist/__tests__/worktree.test.d.ts.map +1 -0
- package/dist/__tests__/worktree.test.js +119 -0
- package/dist/__tests__/worktree.test.js.map +1 -0
- package/dist/adapter.d.ts +75 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +3 -0
- package/dist/adapter.js.map +1 -0
- package/dist/diff-parser.d.ts +3 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +38 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/fixy-commands.d.ts +25 -0
- package/dist/fixy-commands.d.ts.map +1 -0
- package/dist/fixy-commands.js +154 -0
- package/dist/fixy-commands.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +36 -0
- package/dist/paths.js.map +1 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +32 -0
- package/dist/registry.js.map +1 -0
- package/dist/router.d.ts +21 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +36 -0
- package/dist/router.js.map +1 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +127 -0
- package/dist/store.js.map +1 -0
- package/dist/thread.d.ts +47 -0
- package/dist/thread.d.ts.map +1 -0
- package/dist/thread.js +3 -0
- package/dist/thread.js.map +1 -0
- package/dist/turn.d.ts +20 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +130 -0
- package/dist/turn.js.map +1 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +91 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/diff-parser.test.ts +99 -0
- package/src/__tests__/fixy-commands.test.ts +231 -0
- package/src/__tests__/registry.test.ts +79 -0
- package/src/__tests__/router.test.ts +91 -0
- package/src/__tests__/smoke.test.ts +7 -0
- package/src/__tests__/store.test.ts +151 -0
- package/src/__tests__/turn.test.ts +266 -0
- package/src/__tests__/worktree.test.ts +155 -0
- package/src/adapter.ts +84 -0
- package/src/diff-parser.ts +46 -0
- package/src/fixy-commands.ts +201 -0
- package/src/index.ts +40 -0
- package/src/paths.ts +43 -0
- package/src/registry.ts +40 -0
- package/src/router.ts +49 -0
- package/src/store.ts +164 -0
- package/src/thread.ts +50 -0
- package/src/turn.ts +165 -0
- package/src/worktree.ts +119 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// packages/core/src/__tests__/worktree.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
import { WorktreeManager } from '../worktree.js';
|
|
11
|
+
import type { WorktreeHandle } from '../worktree.js';
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Setup / teardown
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let tempDir: string;
|
|
20
|
+
let projectRoot: string;
|
|
21
|
+
let manager: WorktreeManager;
|
|
22
|
+
const THREAD_ID = 'test-thread-001';
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
tempDir = await mkdtemp(join(tmpdir(), 'fixy-wt-test-'));
|
|
26
|
+
process.env['FIXY_HOME'] = join(tempDir, 'fixy-home');
|
|
27
|
+
|
|
28
|
+
// Create a real git repo as fixture
|
|
29
|
+
projectRoot = join(tempDir, 'repo');
|
|
30
|
+
await execFileAsync('mkdir', ['-p', projectRoot]);
|
|
31
|
+
await execFileAsync('git', ['init'], { cwd: projectRoot });
|
|
32
|
+
await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: projectRoot });
|
|
33
|
+
await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: projectRoot });
|
|
34
|
+
// Need at least one commit for worktrees
|
|
35
|
+
await writeFile(join(projectRoot, 'README.md'), '# Test\n');
|
|
36
|
+
await execFileAsync('git', ['add', '.'], { cwd: projectRoot });
|
|
37
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: projectRoot });
|
|
38
|
+
|
|
39
|
+
manager = new WorktreeManager();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
delete process.env['FIXY_HOME'];
|
|
44
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tests
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('WorktreeManager', { timeout: 15000 }, () => {
|
|
52
|
+
it('ensure() creates a worktree with correct handle fields', async () => {
|
|
53
|
+
const handle: WorktreeHandle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
54
|
+
|
|
55
|
+
// Shape
|
|
56
|
+
expect(handle.agentId).toBe('claude');
|
|
57
|
+
expect(handle.threadId).toBe(THREAD_ID);
|
|
58
|
+
expect(handle.branch).toBe('fixy/test-thread-001-claude');
|
|
59
|
+
expect(handle.path).toContain('test-thread-001');
|
|
60
|
+
expect(handle.path).toContain('claude');
|
|
61
|
+
|
|
62
|
+
// Directory actually exists on disk
|
|
63
|
+
await expect(access(handle.path)).resolves.toBeUndefined();
|
|
64
|
+
|
|
65
|
+
// git worktree list shows the path
|
|
66
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
|
67
|
+
cwd: projectRoot,
|
|
68
|
+
});
|
|
69
|
+
expect(stdout).toContain(handle.path);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('ensure() is idempotent — second call returns same handle, no error', async () => {
|
|
73
|
+
const handle1 = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
74
|
+
const handle2 = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
75
|
+
|
|
76
|
+
expect(handle2.path).toBe(handle1.path);
|
|
77
|
+
expect(handle2.branch).toBe(handle1.branch);
|
|
78
|
+
expect(handle2.agentId).toBe(handle1.agentId);
|
|
79
|
+
expect(handle2.threadId).toBe(handle1.threadId);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('collectPatches() returns patches for modified tracked files', async () => {
|
|
83
|
+
const handle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
84
|
+
|
|
85
|
+
// README.md was tracked from the init commit and is present in the worktree
|
|
86
|
+
await writeFile(join(handle.path, 'README.md'), '# Test\n\nAdded a line.\n');
|
|
87
|
+
|
|
88
|
+
const patches = await manager.collectPatches(handle);
|
|
89
|
+
|
|
90
|
+
expect(patches).toHaveLength(1);
|
|
91
|
+
expect(patches[0].relativePath).toBe('README.md');
|
|
92
|
+
// One line added
|
|
93
|
+
expect(patches[0].stats.additions).toBeGreaterThanOrEqual(1);
|
|
94
|
+
// Original line counted as deletion since it was replaced
|
|
95
|
+
expect(patches[0].stats.deletions).toBeGreaterThanOrEqual(0);
|
|
96
|
+
expect(patches[0].diff).toContain('diff --git');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('collectPatches() returns empty array on clean worktree', async () => {
|
|
100
|
+
const handle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
101
|
+
|
|
102
|
+
const patches = await manager.collectPatches(handle);
|
|
103
|
+
|
|
104
|
+
expect(patches).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('reset() re-provisions a clean worktree', async () => {
|
|
108
|
+
const handle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
109
|
+
|
|
110
|
+
// Dirty the worktree
|
|
111
|
+
await writeFile(join(handle.path, 'README.md'), '# Dirty\n');
|
|
112
|
+
|
|
113
|
+
await manager.reset(handle, projectRoot);
|
|
114
|
+
|
|
115
|
+
// The same path should exist again (re-provisioned)
|
|
116
|
+
await expect(access(handle.path)).resolves.toBeUndefined();
|
|
117
|
+
|
|
118
|
+
// collectPatches on the fresh handle should be empty
|
|
119
|
+
const freshHandle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
120
|
+
const patches = await manager.collectPatches(freshHandle);
|
|
121
|
+
expect(patches).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('remove() permanently removes the worktree directory and its branch', async () => {
|
|
125
|
+
const handle = await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
126
|
+
|
|
127
|
+
await manager.remove(handle, projectRoot);
|
|
128
|
+
|
|
129
|
+
// Directory no longer exists
|
|
130
|
+
await expect(access(handle.path)).rejects.toThrow();
|
|
131
|
+
|
|
132
|
+
// Branch is deleted
|
|
133
|
+
const { stdout } = await execFileAsync('git', ['branch', '--list', handle.branch], {
|
|
134
|
+
cwd: projectRoot,
|
|
135
|
+
});
|
|
136
|
+
expect(stdout.trim()).toBe('');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('list() returns all worktrees registered for a thread', async () => {
|
|
140
|
+
await manager.ensure(projectRoot, THREAD_ID, 'claude');
|
|
141
|
+
await manager.ensure(projectRoot, THREAD_ID, 'codex');
|
|
142
|
+
|
|
143
|
+
const handles = await manager.list(THREAD_ID);
|
|
144
|
+
|
|
145
|
+
expect(handles).toHaveLength(2);
|
|
146
|
+
const agentIds = handles.map((h) => h.agentId);
|
|
147
|
+
expect(agentIds).toContain('claude');
|
|
148
|
+
expect(agentIds).toContain('codex');
|
|
149
|
+
|
|
150
|
+
// All handles should report the correct threadId
|
|
151
|
+
for (const h of handles) {
|
|
152
|
+
expect(h.threadId).toBe(THREAD_ID);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// packages/core/src/adapter.ts
|
|
2
|
+
|
|
3
|
+
import type { FixyMessage, FixyPatch } from './thread.js';
|
|
4
|
+
|
|
5
|
+
export interface FixyAgent {
|
|
6
|
+
/** Stable handle without '@', e.g. "claude", "codex". */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Display name shown in the terminal. */
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FixyThreadContext {
|
|
13
|
+
threadId: string;
|
|
14
|
+
projectRoot: string; // absolute path to the git repo the thread lives in
|
|
15
|
+
worktreePath: string; // absolute path to the (thread, agent) worktree
|
|
16
|
+
repoRef: string | null; // branch or commit the worktree was created from
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FixyExecutionContext {
|
|
20
|
+
runId: string; // unique per adapter invocation
|
|
21
|
+
agent: FixyAgent; // which adapter is being invoked
|
|
22
|
+
threadContext: FixyThreadContext;
|
|
23
|
+
/** Full normalized message history Fixy decided to send this turn. */
|
|
24
|
+
messages: FixyMessage[];
|
|
25
|
+
/** Fresh user input for this turn, already stripped of the @mention prefix. */
|
|
26
|
+
prompt: string;
|
|
27
|
+
/** Opaque adapter-owned state from the previous turn in this thread. */
|
|
28
|
+
session: FixySession | null;
|
|
29
|
+
/** Streamed stdout/stderr chunks. Adapters MUST call this. */
|
|
30
|
+
onLog: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
31
|
+
/** Called once with the resolved command + args + env for transcript/logging. */
|
|
32
|
+
onMeta: (meta: FixyInvocationMeta) => void;
|
|
33
|
+
/** Called with the child pid the moment the process spawns. */
|
|
34
|
+
onSpawn: (pid: number) => void;
|
|
35
|
+
/** Abort signal propagated from `/reset` and Ctrl-C. */
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FixyExecutionResult {
|
|
40
|
+
exitCode: number | null;
|
|
41
|
+
signal: string | null;
|
|
42
|
+
timedOut: boolean;
|
|
43
|
+
/** Human-readable summary the router appends to the thread. */
|
|
44
|
+
summary: string;
|
|
45
|
+
/** Opaque per-adapter state to persist for the next turn (e.g. Claude session id). */
|
|
46
|
+
session: FixySession | null;
|
|
47
|
+
/** Any patches/diffs the adapter produced, keyed by file path. */
|
|
48
|
+
patches: FixyPatch[];
|
|
49
|
+
/** Non-fatal warnings shown to the user after the turn completes. */
|
|
50
|
+
warnings: string[];
|
|
51
|
+
errorMessage: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FixySession {
|
|
55
|
+
/** Adapter-native session id, e.g. Claude `--resume` id or Codex thread id. */
|
|
56
|
+
sessionId: string;
|
|
57
|
+
/** Any adapter-specific params needed to resume. Must be JSON-serializable. */
|
|
58
|
+
params: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FixyInvocationMeta {
|
|
62
|
+
resolvedCommand: string;
|
|
63
|
+
args: string[];
|
|
64
|
+
cwd: string;
|
|
65
|
+
env: Record<string, string>; // already redacted of secrets by the adapter
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FixyAdapter {
|
|
69
|
+
/** Stable id, matches the mention handle without '@'. */
|
|
70
|
+
readonly id: string;
|
|
71
|
+
/** Human-readable name for `/status`. */
|
|
72
|
+
readonly name: string;
|
|
73
|
+
/** Verify the CLI is installed and the user's auth is valid. */
|
|
74
|
+
probe(): Promise<FixyProbeResult>;
|
|
75
|
+
/** Run one turn. Must honor `ctx.signal`. */
|
|
76
|
+
execute(ctx: FixyExecutionContext): Promise<FixyExecutionResult>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FixyProbeResult {
|
|
80
|
+
available: boolean;
|
|
81
|
+
version: string | null;
|
|
82
|
+
authStatus: 'ok' | 'needs_login' | 'unknown';
|
|
83
|
+
detail: string | null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// packages/core/src/diff-parser.ts
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { FixyPatch } from './thread.js';
|
|
4
|
+
|
|
5
|
+
export function parseUnifiedDiff(diffOutput: string, worktreePath: string): FixyPatch[] {
|
|
6
|
+
if (!diffOutput || !diffOutput.trim()) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Split on diff --git boundaries using lookahead to keep the delimiter with each segment
|
|
11
|
+
const segments = diffOutput.split(/(?=^diff --git )/m).filter((segment) => segment.trim());
|
|
12
|
+
|
|
13
|
+
const patches: FixyPatch[] = [];
|
|
14
|
+
|
|
15
|
+
for (const segment of segments) {
|
|
16
|
+
// Extract file path from the diff --git header line (b/ side)
|
|
17
|
+
const headerMatch = /^diff --git a\/.+ b\/(.+)$/m.exec(segment);
|
|
18
|
+
if (!headerMatch) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const relativePath = headerMatch[1].trim();
|
|
23
|
+
const filePath = join(worktreePath, relativePath);
|
|
24
|
+
|
|
25
|
+
let additions = 0;
|
|
26
|
+
let deletions = 0;
|
|
27
|
+
|
|
28
|
+
const lines = segment.split('\n');
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
31
|
+
additions++;
|
|
32
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
33
|
+
deletions++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
patches.push({
|
|
38
|
+
filePath,
|
|
39
|
+
relativePath,
|
|
40
|
+
diff: segment,
|
|
41
|
+
stats: { additions, deletions },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return patches;
|
|
46
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import type { FixyExecutionContext } from './adapter.js';
|
|
5
|
+
import type { AdapterRegistry } from './registry.js';
|
|
6
|
+
import type { LocalThreadStore } from './store.js';
|
|
7
|
+
import type { FixyMessage, FixyThread } from './thread.js';
|
|
8
|
+
import type { WorktreeManager } from './worktree.js';
|
|
9
|
+
import { getThreadFile } from './paths.js';
|
|
10
|
+
|
|
11
|
+
export interface FixyCommandContext {
|
|
12
|
+
thread: FixyThread;
|
|
13
|
+
rest: string;
|
|
14
|
+
store: LocalThreadStore;
|
|
15
|
+
registry: AdapterRegistry;
|
|
16
|
+
worktreeManager: WorktreeManager;
|
|
17
|
+
onLog: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FixyCommandRunner {
|
|
22
|
+
async run(ctx: FixyCommandContext): Promise<void> {
|
|
23
|
+
const { rest } = ctx;
|
|
24
|
+
|
|
25
|
+
if (!rest.startsWith('/')) {
|
|
26
|
+
await this._handleBare(rest, ctx);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spaceIdx = rest.indexOf(' ');
|
|
31
|
+
const command = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
|
32
|
+
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1).trim();
|
|
33
|
+
|
|
34
|
+
switch (command) {
|
|
35
|
+
case '/worker':
|
|
36
|
+
await this._handleWorker(args, ctx);
|
|
37
|
+
break;
|
|
38
|
+
case '/all':
|
|
39
|
+
await this._handleAll(ctx);
|
|
40
|
+
break;
|
|
41
|
+
case '/settings':
|
|
42
|
+
await this._handleSettings(ctx);
|
|
43
|
+
break;
|
|
44
|
+
case '/reset':
|
|
45
|
+
await this._handleReset(ctx);
|
|
46
|
+
break;
|
|
47
|
+
case '/status':
|
|
48
|
+
await this._handleStatus(ctx);
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
await this._appendSystemMessage(`unknown command: ${command}`, ctx);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async _handleWorker(adapterId: string, ctx: FixyCommandContext): Promise<void> {
|
|
56
|
+
ctx.registry.require(adapterId);
|
|
57
|
+
|
|
58
|
+
const fresh = await ctx.store.getThread(ctx.thread.id, ctx.thread.projectRoot);
|
|
59
|
+
fresh.workerModel = adapterId;
|
|
60
|
+
fresh.updatedAt = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
const sysMsg: FixyMessage = {
|
|
63
|
+
id: randomUUID(),
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
role: 'system',
|
|
66
|
+
agentId: null,
|
|
67
|
+
content: `worker set to ${adapterId}`,
|
|
68
|
+
runId: null,
|
|
69
|
+
dispatchedTo: [],
|
|
70
|
+
patches: [],
|
|
71
|
+
warnings: [],
|
|
72
|
+
};
|
|
73
|
+
fresh.messages.push(sysMsg);
|
|
74
|
+
|
|
75
|
+
await this._persistThread(fresh);
|
|
76
|
+
ctx.thread.workerModel = adapterId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async _handleAll(ctx: FixyCommandContext): Promise<void> {
|
|
80
|
+
await this._appendSystemMessage(
|
|
81
|
+
'collaboration engine not yet implemented — arriving in Step 12',
|
|
82
|
+
ctx,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async _handleSettings(ctx: FixyCommandContext): Promise<void> {
|
|
87
|
+
await this._appendSystemMessage('settings command not yet implemented', ctx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async _handleReset(ctx: FixyCommandContext): Promise<void> {
|
|
91
|
+
ctx.thread.agentSessions = {};
|
|
92
|
+
|
|
93
|
+
for (const [agentId, worktreePath] of Object.entries(ctx.thread.worktrees)) {
|
|
94
|
+
const handle = {
|
|
95
|
+
path: worktreePath,
|
|
96
|
+
branch: `fixy/${ctx.thread.id}-${agentId}`,
|
|
97
|
+
agentId,
|
|
98
|
+
threadId: ctx.thread.id,
|
|
99
|
+
};
|
|
100
|
+
await ctx.worktreeManager.reset(handle, ctx.thread.projectRoot);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fresh = await ctx.store.getThread(ctx.thread.id, ctx.thread.projectRoot);
|
|
104
|
+
fresh.agentSessions = {};
|
|
105
|
+
fresh.updatedAt = new Date().toISOString();
|
|
106
|
+
await this._persistThread(fresh);
|
|
107
|
+
|
|
108
|
+
await this._appendSystemMessage(
|
|
109
|
+
'thread reset — all agent sessions cleared, worktrees re-provisioned',
|
|
110
|
+
ctx,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async _handleStatus(ctx: FixyCommandContext): Promise<void> {
|
|
115
|
+
const adapters = ctx.registry.list();
|
|
116
|
+
const lines: string[] = [`worker: ${ctx.thread.workerModel}`, ''];
|
|
117
|
+
|
|
118
|
+
for (const adapter of adapters) {
|
|
119
|
+
const probe = await adapter.probe();
|
|
120
|
+
const session = ctx.thread.agentSessions[adapter.id];
|
|
121
|
+
const sessionId = session ? session.sessionId : 'none';
|
|
122
|
+
|
|
123
|
+
lines.push(`adapter: ${adapter.id}`);
|
|
124
|
+
lines.push(` name: ${adapter.name}`);
|
|
125
|
+
lines.push(` available: ${probe.available ? 'yes' : 'no'}`);
|
|
126
|
+
lines.push(` version: ${probe.version ?? 'unknown'}`);
|
|
127
|
+
lines.push(` auth: ${probe.authStatus}`);
|
|
128
|
+
lines.push(` session: ${sessionId}`);
|
|
129
|
+
if (probe.detail) {
|
|
130
|
+
lines.push(` detail: ${probe.detail}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await this._appendSystemMessage(lines.join('\n').trimEnd(), ctx);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async _handleBare(prompt: string, ctx: FixyCommandContext): Promise<void> {
|
|
139
|
+
const { thread } = ctx;
|
|
140
|
+
const adapter = ctx.registry.require(thread.workerModel);
|
|
141
|
+
const runId = randomUUID();
|
|
142
|
+
|
|
143
|
+
const execCtx: FixyExecutionContext = {
|
|
144
|
+
runId,
|
|
145
|
+
agent: { id: adapter.id, name: adapter.name },
|
|
146
|
+
threadContext: {
|
|
147
|
+
threadId: thread.id,
|
|
148
|
+
projectRoot: thread.projectRoot,
|
|
149
|
+
worktreePath: thread.projectRoot,
|
|
150
|
+
repoRef: null,
|
|
151
|
+
},
|
|
152
|
+
messages: thread.messages,
|
|
153
|
+
prompt,
|
|
154
|
+
session: thread.agentSessions[thread.workerModel] ?? null,
|
|
155
|
+
onLog: ctx.onLog,
|
|
156
|
+
onMeta: () => {},
|
|
157
|
+
onSpawn: () => {},
|
|
158
|
+
signal: ctx.signal,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const result = await adapter.execute(execCtx);
|
|
162
|
+
|
|
163
|
+
thread.agentSessions[thread.workerModel] = result.session;
|
|
164
|
+
|
|
165
|
+
const agentMsg: FixyMessage = {
|
|
166
|
+
id: randomUUID(),
|
|
167
|
+
createdAt: new Date().toISOString(),
|
|
168
|
+
role: 'agent',
|
|
169
|
+
agentId: 'fixy',
|
|
170
|
+
content: result.summary,
|
|
171
|
+
runId,
|
|
172
|
+
dispatchedTo: [],
|
|
173
|
+
patches: result.patches,
|
|
174
|
+
warnings: result.warnings,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
await ctx.store.appendMessage(thread.id, thread.projectRoot, agentMsg);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async _persistThread(thread: FixyThread): Promise<void> {
|
|
181
|
+
const filePath = getThreadFile(thread.projectRoot, thread.id);
|
|
182
|
+
const tmpPath = `${filePath}.tmp`;
|
|
183
|
+
await writeFile(tmpPath, JSON.stringify(thread, null, 2), 'utf8');
|
|
184
|
+
await rename(tmpPath, filePath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async _appendSystemMessage(content: string, ctx: FixyCommandContext): Promise<void> {
|
|
188
|
+
const msg: FixyMessage = {
|
|
189
|
+
id: randomUUID(),
|
|
190
|
+
createdAt: new Date().toISOString(),
|
|
191
|
+
role: 'system',
|
|
192
|
+
agentId: null,
|
|
193
|
+
content,
|
|
194
|
+
runId: null,
|
|
195
|
+
dispatchedTo: [],
|
|
196
|
+
patches: [],
|
|
197
|
+
warnings: [],
|
|
198
|
+
};
|
|
199
|
+
await ctx.store.appendMessage(ctx.thread.id, ctx.thread.projectRoot, msg);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type { FixyRole, FixyThread, FixyMessage, FixyPatch } from './thread.js';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
FixyAgent,
|
|
5
|
+
FixyThreadContext,
|
|
6
|
+
FixyExecutionContext,
|
|
7
|
+
FixyExecutionResult,
|
|
8
|
+
FixySession,
|
|
9
|
+
FixyInvocationMeta,
|
|
10
|
+
FixyAdapter,
|
|
11
|
+
FixyProbeResult,
|
|
12
|
+
} from './adapter.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
getFixyHome,
|
|
16
|
+
getConfigPath,
|
|
17
|
+
computeProjectId,
|
|
18
|
+
getProjectDir,
|
|
19
|
+
getProjectFile,
|
|
20
|
+
getThreadsDir,
|
|
21
|
+
getThreadFile,
|
|
22
|
+
getWorktreesDir,
|
|
23
|
+
} from './paths.js';
|
|
24
|
+
|
|
25
|
+
export { LocalThreadStore } from './store.js';
|
|
26
|
+
|
|
27
|
+
export { AdapterRegistry } from './registry.js';
|
|
28
|
+
|
|
29
|
+
export { Router } from './router.js';
|
|
30
|
+
export type { ParsedInput } from './router.js';
|
|
31
|
+
|
|
32
|
+
export { TurnController } from './turn.js';
|
|
33
|
+
export type { TurnParams } from './turn.js';
|
|
34
|
+
|
|
35
|
+
export { WorktreeManager } from './worktree.js';
|
|
36
|
+
export type { WorktreeHandle } from './worktree.js';
|
|
37
|
+
export { parseUnifiedDiff } from './diff-parser.js';
|
|
38
|
+
|
|
39
|
+
export { FixyCommandRunner } from './fixy-commands.js';
|
|
40
|
+
export type { FixyCommandContext } from './fixy-commands.js';
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Returns the Fixy home directory. Respects FIXY_HOME env var for testing. */
|
|
6
|
+
export function getFixyHome(): string {
|
|
7
|
+
return process.env['FIXY_HOME'] ?? join(homedir(), '.fixy');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Returns the path to the global config file. */
|
|
11
|
+
export function getConfigPath(): string {
|
|
12
|
+
return join(getFixyHome(), 'config.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Computes the project id (sha1 hex digest of the absolute projectRoot). */
|
|
16
|
+
export function computeProjectId(projectRoot: string): string {
|
|
17
|
+
return createHash('sha1').update(projectRoot).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns the project directory for a given projectRoot. */
|
|
21
|
+
export function getProjectDir(projectRoot: string): string {
|
|
22
|
+
return join(getFixyHome(), 'projects', computeProjectId(projectRoot));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Returns the project.json path for a given projectRoot. */
|
|
26
|
+
export function getProjectFile(projectRoot: string): string {
|
|
27
|
+
return join(getProjectDir(projectRoot), 'project.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns the threads directory for a given projectRoot. */
|
|
31
|
+
export function getThreadsDir(projectRoot: string): string {
|
|
32
|
+
return join(getProjectDir(projectRoot), 'threads');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns the full path to a thread JSON file. */
|
|
36
|
+
export function getThreadFile(projectRoot: string, threadId: string): string {
|
|
37
|
+
return join(getThreadsDir(projectRoot), `${threadId}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the worktrees directory for a given threadId. */
|
|
41
|
+
export function getWorktreesDir(threadId: string): string {
|
|
42
|
+
return join(getFixyHome(), 'worktrees', threadId);
|
|
43
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FixyAdapter } from './adapter.js';
|
|
2
|
+
|
|
3
|
+
export class AdapterRegistry {
|
|
4
|
+
private readonly adapters = new Map<string, FixyAdapter>();
|
|
5
|
+
|
|
6
|
+
register(adapter: FixyAdapter): void {
|
|
7
|
+
if (this.adapters.has(adapter.id)) {
|
|
8
|
+
throw new Error(`Adapter already registered: ${adapter.id}`);
|
|
9
|
+
}
|
|
10
|
+
this.adapters.set(adapter.id, adapter);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
unregister(id: string): void {
|
|
14
|
+
this.adapters.delete(id);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
require(id: string): FixyAdapter {
|
|
18
|
+
const adapter = this.adapters.get(id);
|
|
19
|
+
if (adapter === undefined) {
|
|
20
|
+
throw new Error(`Unknown adapter: ${id}`);
|
|
21
|
+
}
|
|
22
|
+
return adapter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(id: string): FixyAdapter | undefined {
|
|
26
|
+
return this.adapters.get(id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
list(): FixyAdapter[] {
|
|
30
|
+
return Array.from(this.adapters.values());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
has(id: string): boolean {
|
|
34
|
+
return this.adapters.has(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clear(): void {
|
|
38
|
+
this.adapters.clear();
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// packages/core/src/router.ts
|
|
2
|
+
|
|
3
|
+
import type { AdapterRegistry } from './registry.js';
|
|
4
|
+
|
|
5
|
+
export type ParsedInput =
|
|
6
|
+
| { kind: 'mention'; agentIds: string[]; body: string }
|
|
7
|
+
| { kind: 'fixy'; rest: string }
|
|
8
|
+
| { kind: 'bare'; body: string }
|
|
9
|
+
| { kind: 'error'; reason: string };
|
|
10
|
+
|
|
11
|
+
const MENTION_RE = /^@(\w+)/;
|
|
12
|
+
|
|
13
|
+
export class Router {
|
|
14
|
+
constructor(private readonly registry: AdapterRegistry) {}
|
|
15
|
+
|
|
16
|
+
parse(input: string): ParsedInput {
|
|
17
|
+
if (!input.startsWith('@')) {
|
|
18
|
+
return { kind: 'bare', body: input };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tokens: string[] = [];
|
|
22
|
+
let remaining = input;
|
|
23
|
+
|
|
24
|
+
while (remaining.startsWith('@')) {
|
|
25
|
+
const match = MENTION_RE.exec(remaining);
|
|
26
|
+
if (!match) break;
|
|
27
|
+
|
|
28
|
+
// @fixy as first token: everything after it is the rest
|
|
29
|
+
if (tokens.length === 0 && match[1] === 'fixy') {
|
|
30
|
+
return { kind: 'fixy', rest: remaining.slice(match[0].length).trimStart() };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
tokens.push(match[1]);
|
|
34
|
+
remaining = remaining.slice(match[0].length).trimStart();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (tokens.length === 0) {
|
|
38
|
+
return { kind: 'bare', body: input };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const token of tokens) {
|
|
42
|
+
if (!this.registry.has(token)) {
|
|
43
|
+
return { kind: 'error', reason: `unknown agent: @${token}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { kind: 'mention', agentIds: tokens, body: remaining };
|
|
48
|
+
}
|
|
49
|
+
}
|