@doingdev/opencode-claude-manager-plugin 0.1.22 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -40
- package/dist/claude/claude-agent-sdk-adapter.d.ts +27 -0
- package/dist/claude/claude-agent-sdk-adapter.js +520 -0
- package/dist/claude/claude-session.service.d.ts +15 -0
- package/dist/claude/claude-session.service.js +23 -0
- package/dist/claude/session-live-tailer.d.ts +51 -0
- package/dist/claude/session-live-tailer.js +269 -0
- package/dist/claude/tool-approval-manager.d.ts +27 -0
- package/dist/claude/tool-approval-manager.js +238 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +4 -5
- package/dist/manager/context-tracker.d.ts +33 -0
- package/dist/manager/context-tracker.js +108 -0
- package/dist/manager/git-operations.d.ts +12 -0
- package/dist/manager/git-operations.js +76 -0
- package/dist/manager/persistent-manager.d.ts +74 -0
- package/dist/manager/persistent-manager.js +167 -0
- package/dist/manager/session-controller.d.ts +45 -0
- package/dist/manager/session-controller.js +147 -0
- package/dist/metadata/claude-metadata.service.d.ts +12 -0
- package/dist/metadata/claude-metadata.service.js +38 -0
- package/dist/metadata/repo-claude-config-reader.d.ts +7 -0
- package/dist/metadata/repo-claude-config-reader.js +154 -0
- package/dist/plugin/agent-hierarchy.d.ts +47 -0
- package/dist/plugin/agent-hierarchy.js +110 -0
- package/dist/plugin/claude-manager.plugin.d.ts +2 -0
- package/dist/plugin/claude-manager.plugin.js +490 -0
- package/dist/plugin/orchestrator.plugin.js +4 -2
- package/dist/plugin/service-factory.d.ts +12 -0
- package/dist/plugin/service-factory.js +41 -0
- package/dist/prompts/registry.d.ts +2 -8
- package/dist/prompts/registry.js +203 -29
- package/dist/state/file-run-state-store.d.ts +14 -0
- package/dist/state/file-run-state-store.js +87 -0
- package/dist/state/transcript-store.d.ts +15 -0
- package/dist/state/transcript-store.js +44 -0
- package/dist/types/contracts.d.ts +216 -0
- package/dist/types/contracts.js +1 -0
- package/dist/util/fs-helpers.d.ts +2 -0
- package/dist/util/fs-helpers.js +12 -0
- package/dist/util/transcript-append.d.ts +7 -0
- package/dist/util/transcript-append.js +29 -0
- package/package.json +5 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
2
|
+
export class ContextTracker {
|
|
3
|
+
totalTurns = 0;
|
|
4
|
+
totalCostUsd = 0;
|
|
5
|
+
latestInputTokens = null;
|
|
6
|
+
latestOutputTokens = null;
|
|
7
|
+
contextWindowSize = null;
|
|
8
|
+
compactionCount = 0;
|
|
9
|
+
sessionId = null;
|
|
10
|
+
recordResult(result) {
|
|
11
|
+
if (result.sessionId) {
|
|
12
|
+
this.sessionId = result.sessionId;
|
|
13
|
+
}
|
|
14
|
+
if (result.turns !== undefined) {
|
|
15
|
+
this.totalTurns = result.turns;
|
|
16
|
+
}
|
|
17
|
+
if (result.totalCostUsd !== undefined) {
|
|
18
|
+
this.totalCostUsd = result.totalCostUsd;
|
|
19
|
+
}
|
|
20
|
+
if (result.inputTokens !== undefined) {
|
|
21
|
+
// If input tokens dropped significantly, compaction likely occurred
|
|
22
|
+
if (this.latestInputTokens !== null &&
|
|
23
|
+
result.inputTokens < this.latestInputTokens * 0.5) {
|
|
24
|
+
this.compactionCount++;
|
|
25
|
+
}
|
|
26
|
+
this.latestInputTokens = result.inputTokens;
|
|
27
|
+
}
|
|
28
|
+
if (result.outputTokens !== undefined) {
|
|
29
|
+
this.latestOutputTokens = result.outputTokens;
|
|
30
|
+
}
|
|
31
|
+
if (result.contextWindowSize !== undefined) {
|
|
32
|
+
this.contextWindowSize = result.contextWindowSize;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
recordCompaction() {
|
|
36
|
+
this.compactionCount++;
|
|
37
|
+
}
|
|
38
|
+
snapshot() {
|
|
39
|
+
return {
|
|
40
|
+
sessionId: this.sessionId,
|
|
41
|
+
totalTurns: this.totalTurns,
|
|
42
|
+
totalCostUsd: this.totalCostUsd,
|
|
43
|
+
latestInputTokens: this.latestInputTokens,
|
|
44
|
+
latestOutputTokens: this.latestOutputTokens,
|
|
45
|
+
contextWindowSize: this.contextWindowSize,
|
|
46
|
+
estimatedContextPercent: this.estimateContextPercent(),
|
|
47
|
+
warningLevel: this.warningLevel(),
|
|
48
|
+
compactionCount: this.compactionCount,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
warningLevel() {
|
|
52
|
+
const percent = this.estimateContextPercent();
|
|
53
|
+
if (percent === null) {
|
|
54
|
+
return 'ok';
|
|
55
|
+
}
|
|
56
|
+
if (percent >= 85) {
|
|
57
|
+
return 'critical';
|
|
58
|
+
}
|
|
59
|
+
if (percent >= 70) {
|
|
60
|
+
return 'high';
|
|
61
|
+
}
|
|
62
|
+
if (percent >= 50) {
|
|
63
|
+
return 'moderate';
|
|
64
|
+
}
|
|
65
|
+
return 'ok';
|
|
66
|
+
}
|
|
67
|
+
estimateContextPercent() {
|
|
68
|
+
// Tier 1: Token-based (most accurate)
|
|
69
|
+
if (this.latestInputTokens !== null) {
|
|
70
|
+
const window = this.contextWindowSize ?? DEFAULT_CONTEXT_WINDOW;
|
|
71
|
+
return Math.min(100, Math.round((this.latestInputTokens / window) * 100));
|
|
72
|
+
}
|
|
73
|
+
// Tier 2: Cost-based heuristic
|
|
74
|
+
if (this.totalCostUsd > 0) {
|
|
75
|
+
const estimatedTokens = this.totalCostUsd * 130_000;
|
|
76
|
+
const window = this.contextWindowSize ?? DEFAULT_CONTEXT_WINDOW;
|
|
77
|
+
return Math.min(100, Math.round((estimatedTokens / window) * 100));
|
|
78
|
+
}
|
|
79
|
+
// Tier 3: Turns-based fallback
|
|
80
|
+
if (this.totalTurns > 0) {
|
|
81
|
+
const estimatedTokens = this.totalTurns * 6_000;
|
|
82
|
+
const window = this.contextWindowSize ?? DEFAULT_CONTEXT_WINDOW;
|
|
83
|
+
return Math.min(100, Math.round((estimatedTokens / window) * 100));
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
isAboveTokenThreshold(thresholdTokens = 200_000) {
|
|
88
|
+
return (this.latestInputTokens !== null &&
|
|
89
|
+
this.latestInputTokens >= thresholdTokens);
|
|
90
|
+
}
|
|
91
|
+
reset() {
|
|
92
|
+
this.totalTurns = 0;
|
|
93
|
+
this.totalCostUsd = 0;
|
|
94
|
+
this.latestInputTokens = null;
|
|
95
|
+
this.latestOutputTokens = null;
|
|
96
|
+
this.contextWindowSize = null;
|
|
97
|
+
this.compactionCount = 0;
|
|
98
|
+
this.sessionId = null;
|
|
99
|
+
}
|
|
100
|
+
/** Restore from persisted active session state. */
|
|
101
|
+
restore(state) {
|
|
102
|
+
this.sessionId = state.sessionId;
|
|
103
|
+
this.totalTurns = state.totalTurns;
|
|
104
|
+
this.totalCostUsd = state.totalCostUsd;
|
|
105
|
+
this.contextWindowSize = state.contextWindowSize;
|
|
106
|
+
this.latestInputTokens = state.latestInputTokens;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GitDiffResult, GitOperationResult } from '../types/contracts.js';
|
|
2
|
+
export declare class GitOperations {
|
|
3
|
+
private readonly cwd;
|
|
4
|
+
constructor(cwd: string);
|
|
5
|
+
diff(): Promise<GitDiffResult>;
|
|
6
|
+
diffStat(): Promise<string>;
|
|
7
|
+
commit(message: string): Promise<GitOperationResult>;
|
|
8
|
+
resetHard(): Promise<GitOperationResult>;
|
|
9
|
+
currentBranch(): Promise<string>;
|
|
10
|
+
recentCommits(count?: number): Promise<string>;
|
|
11
|
+
private git;
|
|
12
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export class GitOperations {
|
|
5
|
+
cwd;
|
|
6
|
+
constructor(cwd) {
|
|
7
|
+
this.cwd = cwd;
|
|
8
|
+
}
|
|
9
|
+
async diff() {
|
|
10
|
+
const [diffText, statOutput] = await Promise.all([
|
|
11
|
+
this.git(['diff', 'HEAD']),
|
|
12
|
+
this.git(['diff', 'HEAD', '--stat']),
|
|
13
|
+
]);
|
|
14
|
+
const stats = parseStatLine(statOutput);
|
|
15
|
+
return {
|
|
16
|
+
hasDiff: diffText.trim().length > 0,
|
|
17
|
+
diffText,
|
|
18
|
+
stats,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async diffStat() {
|
|
22
|
+
return this.git(['diff', 'HEAD', '--stat']);
|
|
23
|
+
}
|
|
24
|
+
async commit(message) {
|
|
25
|
+
try {
|
|
26
|
+
await this.git(['add', '-A']);
|
|
27
|
+
const output = await this.git(['commit', '-m', message]);
|
|
28
|
+
return { success: true, output };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
output: '',
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async resetHard() {
|
|
39
|
+
try {
|
|
40
|
+
const output = await this.git(['reset', '--hard', 'HEAD']);
|
|
41
|
+
// Also clean untracked files
|
|
42
|
+
const cleanOutput = await this.git(['clean', '-fd']);
|
|
43
|
+
return { success: true, output: `${output}\n${cleanOutput}`.trim() };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return {
|
|
47
|
+
success: false,
|
|
48
|
+
output: '',
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async currentBranch() {
|
|
54
|
+
const branch = await this.git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
55
|
+
return branch.trim();
|
|
56
|
+
}
|
|
57
|
+
async recentCommits(count = 5) {
|
|
58
|
+
return this.git(['log', `--oneline`, `-${count}`]);
|
|
59
|
+
}
|
|
60
|
+
async git(args) {
|
|
61
|
+
const { stdout } = await execFileAsync('git', args, { cwd: this.cwd });
|
|
62
|
+
return stdout;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function parseStatLine(statOutput) {
|
|
66
|
+
// Parse the summary line like: "3 files changed, 10 insertions(+), 2 deletions(-)"
|
|
67
|
+
const summaryLine = statOutput.trim().split('\n').pop() ?? '';
|
|
68
|
+
const filesMatch = summaryLine.match(/(\d+)\s+files?\s+changed/);
|
|
69
|
+
const insertionsMatch = summaryLine.match(/(\d+)\s+insertions?\(\+\)/);
|
|
70
|
+
const deletionsMatch = summaryLine.match(/(\d+)\s+deletions?\(-\)/);
|
|
71
|
+
return {
|
|
72
|
+
filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
|
|
73
|
+
insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
|
|
74
|
+
deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ClaudeSessionEvent, ClaudeSessionRunResult, PersistentRunRecord, PersistentRunResult, SessionContextSnapshot, GitDiffResult, GitOperationResult } from '../types/contracts.js';
|
|
2
|
+
import type { ClaudeSessionEventHandler } from '../claude/claude-agent-sdk-adapter.js';
|
|
3
|
+
import type { FileRunStateStore } from '../state/file-run-state-store.js';
|
|
4
|
+
import type { TranscriptStore } from '../state/transcript-store.js';
|
|
5
|
+
import type { SessionController } from './session-controller.js';
|
|
6
|
+
import type { GitOperations } from './git-operations.js';
|
|
7
|
+
import type { ContextTracker } from './context-tracker.js';
|
|
8
|
+
type PersistentManagerProgressHandler = (run: PersistentRunRecord) => void | Promise<void>;
|
|
9
|
+
export declare class PersistentManager {
|
|
10
|
+
private readonly sessionController;
|
|
11
|
+
private readonly gitOps;
|
|
12
|
+
private readonly stateStore;
|
|
13
|
+
private readonly contextTracker;
|
|
14
|
+
private readonly transcriptStore;
|
|
15
|
+
constructor(sessionController: SessionController, gitOps: GitOperations, stateStore: FileRunStateStore, contextTracker: ContextTracker, transcriptStore: TranscriptStore);
|
|
16
|
+
/**
|
|
17
|
+
* Send a message to the persistent Claude Code session.
|
|
18
|
+
* Creates a new session if none exists.
|
|
19
|
+
*/
|
|
20
|
+
sendMessage(cwd: string, message: string, options?: {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
23
|
+
mode?: 'plan' | 'free';
|
|
24
|
+
abortSignal?: AbortSignal;
|
|
25
|
+
}, onEvent?: ClaudeSessionEventHandler): Promise<{
|
|
26
|
+
sessionId: string | undefined;
|
|
27
|
+
finalText: string;
|
|
28
|
+
turns?: number;
|
|
29
|
+
totalCostUsd?: number;
|
|
30
|
+
context: SessionContextSnapshot;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Get the current git diff.
|
|
34
|
+
*/
|
|
35
|
+
gitDiff(): Promise<GitDiffResult>;
|
|
36
|
+
/**
|
|
37
|
+
* Commit all current changes.
|
|
38
|
+
*/
|
|
39
|
+
gitCommit(message: string): Promise<GitOperationResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Hard reset to discard all uncommitted changes.
|
|
42
|
+
*/
|
|
43
|
+
gitReset(): Promise<GitOperationResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Get current session status and context health.
|
|
46
|
+
*/
|
|
47
|
+
getStatus(): SessionContextSnapshot;
|
|
48
|
+
/**
|
|
49
|
+
* Clear the active session. Next send creates a fresh one.
|
|
50
|
+
*/
|
|
51
|
+
clearSession(cwd: string): Promise<string | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Compact the current session to free context.
|
|
54
|
+
*/
|
|
55
|
+
compactSession(cwd: string, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Read persisted transcript events for a session.
|
|
58
|
+
*/
|
|
59
|
+
getTranscriptEvents(cwd: string, sessionId: string): Promise<ClaudeSessionEvent[]>;
|
|
60
|
+
/**
|
|
61
|
+
* Execute a full task with run tracking.
|
|
62
|
+
* Creates a run record, sends the message, and persists the result.
|
|
63
|
+
*/
|
|
64
|
+
executeTask(cwd: string, task: string, options?: {
|
|
65
|
+
model?: string;
|
|
66
|
+
}, onProgress?: PersistentManagerProgressHandler): Promise<PersistentRunResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Try to restore session state from disk on startup.
|
|
69
|
+
*/
|
|
70
|
+
tryRestore(cwd: string): Promise<boolean>;
|
|
71
|
+
listRuns(cwd: string): Promise<PersistentRunRecord[]>;
|
|
72
|
+
getRun(cwd: string, runId: string): Promise<PersistentRunRecord | null>;
|
|
73
|
+
}
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export class PersistentManager {
|
|
3
|
+
sessionController;
|
|
4
|
+
gitOps;
|
|
5
|
+
stateStore;
|
|
6
|
+
contextTracker;
|
|
7
|
+
transcriptStore;
|
|
8
|
+
constructor(sessionController, gitOps, stateStore, contextTracker, transcriptStore) {
|
|
9
|
+
this.sessionController = sessionController;
|
|
10
|
+
this.gitOps = gitOps;
|
|
11
|
+
this.stateStore = stateStore;
|
|
12
|
+
this.contextTracker = contextTracker;
|
|
13
|
+
this.transcriptStore = transcriptStore;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Send a message to the persistent Claude Code session.
|
|
17
|
+
* Creates a new session if none exists.
|
|
18
|
+
*/
|
|
19
|
+
async sendMessage(cwd, message, options, onEvent) {
|
|
20
|
+
const result = await this.sessionController.sendMessage(cwd, message, options, onEvent);
|
|
21
|
+
if (result.sessionId && result.events.length > 0) {
|
|
22
|
+
await this.transcriptStore.appendEvents(cwd, result.sessionId, result.events);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
sessionId: result.sessionId,
|
|
26
|
+
finalText: result.finalText,
|
|
27
|
+
turns: result.turns,
|
|
28
|
+
totalCostUsd: result.totalCostUsd,
|
|
29
|
+
context: this.sessionController.getContextSnapshot(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get the current git diff.
|
|
34
|
+
*/
|
|
35
|
+
async gitDiff() {
|
|
36
|
+
return this.gitOps.diff();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Commit all current changes.
|
|
40
|
+
*/
|
|
41
|
+
async gitCommit(message) {
|
|
42
|
+
return this.gitOps.commit(message);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Hard reset to discard all uncommitted changes.
|
|
46
|
+
*/
|
|
47
|
+
async gitReset() {
|
|
48
|
+
return this.gitOps.resetHard();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get current session status and context health.
|
|
52
|
+
*/
|
|
53
|
+
getStatus() {
|
|
54
|
+
return this.sessionController.getContextSnapshot();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Clear the active session. Next send creates a fresh one.
|
|
58
|
+
*/
|
|
59
|
+
async clearSession(cwd) {
|
|
60
|
+
return this.sessionController.clearSession(cwd);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Compact the current session to free context.
|
|
64
|
+
*/
|
|
65
|
+
async compactSession(cwd, onEvent) {
|
|
66
|
+
const result = await this.sessionController.compactSession(cwd, onEvent);
|
|
67
|
+
if (result.sessionId && result.events.length > 0) {
|
|
68
|
+
await this.transcriptStore.appendEvents(cwd, result.sessionId, result.events);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read persisted transcript events for a session.
|
|
74
|
+
*/
|
|
75
|
+
getTranscriptEvents(cwd, sessionId) {
|
|
76
|
+
return this.transcriptStore.readEvents(cwd, sessionId);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Execute a full task with run tracking.
|
|
80
|
+
* Creates a run record, sends the message, and persists the result.
|
|
81
|
+
*/
|
|
82
|
+
async executeTask(cwd, task, options, onProgress) {
|
|
83
|
+
const runId = randomUUID();
|
|
84
|
+
const createdAt = new Date().toISOString();
|
|
85
|
+
const runRecord = {
|
|
86
|
+
id: runId,
|
|
87
|
+
cwd,
|
|
88
|
+
task,
|
|
89
|
+
status: 'running',
|
|
90
|
+
createdAt,
|
|
91
|
+
updatedAt: createdAt,
|
|
92
|
+
sessionId: this.sessionController.sessionId,
|
|
93
|
+
sessionHistory: [],
|
|
94
|
+
messages: [
|
|
95
|
+
{
|
|
96
|
+
timestamp: createdAt,
|
|
97
|
+
direction: 'sent',
|
|
98
|
+
text: task,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
actions: [],
|
|
102
|
+
commits: [],
|
|
103
|
+
context: this.sessionController.getContextSnapshot(),
|
|
104
|
+
};
|
|
105
|
+
await this.stateStore.saveRun(runRecord);
|
|
106
|
+
await onProgress?.(runRecord);
|
|
107
|
+
try {
|
|
108
|
+
const result = await this.sessionController.sendMessage(cwd, task, options, async (event) => {
|
|
109
|
+
// Update run record with progress events
|
|
110
|
+
const currentRun = await this.stateStore.getRun(cwd, runId);
|
|
111
|
+
if (currentRun) {
|
|
112
|
+
const updated = {
|
|
113
|
+
...currentRun,
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
sessionId: event.sessionId ?? currentRun.sessionId,
|
|
116
|
+
context: this.sessionController.getContextSnapshot(),
|
|
117
|
+
};
|
|
118
|
+
await this.stateStore.saveRun(updated);
|
|
119
|
+
await onProgress?.(updated);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const completedRun = await this.stateStore.updateRun(cwd, runId, (run) => ({
|
|
123
|
+
...run,
|
|
124
|
+
status: 'completed',
|
|
125
|
+
updatedAt: new Date().toISOString(),
|
|
126
|
+
sessionId: result.sessionId ?? run.sessionId,
|
|
127
|
+
messages: [
|
|
128
|
+
...run.messages,
|
|
129
|
+
{
|
|
130
|
+
timestamp: new Date().toISOString(),
|
|
131
|
+
direction: 'received',
|
|
132
|
+
text: result.finalText,
|
|
133
|
+
turns: result.turns,
|
|
134
|
+
totalCostUsd: result.totalCostUsd,
|
|
135
|
+
inputTokens: result.inputTokens,
|
|
136
|
+
outputTokens: result.outputTokens,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
context: this.sessionController.getContextSnapshot(),
|
|
140
|
+
finalSummary: result.finalText,
|
|
141
|
+
}));
|
|
142
|
+
return { run: completedRun };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const failedRun = await this.stateStore.updateRun(cwd, runId, (run) => ({
|
|
146
|
+
...run,
|
|
147
|
+
status: 'failed',
|
|
148
|
+
updatedAt: new Date().toISOString(),
|
|
149
|
+
context: this.sessionController.getContextSnapshot(),
|
|
150
|
+
finalSummary: error instanceof Error ? error.message : String(error),
|
|
151
|
+
}));
|
|
152
|
+
return { run: failedRun };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Try to restore session state from disk on startup.
|
|
157
|
+
*/
|
|
158
|
+
async tryRestore(cwd) {
|
|
159
|
+
return this.sessionController.tryRestore(cwd);
|
|
160
|
+
}
|
|
161
|
+
listRuns(cwd) {
|
|
162
|
+
return this.stateStore.listRuns(cwd);
|
|
163
|
+
}
|
|
164
|
+
getRun(cwd, runId) {
|
|
165
|
+
return this.stateStore.getRun(cwd, runId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ClaudeAgentSdkAdapter, ClaudeSessionEventHandler } from '../claude/claude-agent-sdk-adapter.js';
|
|
2
|
+
import type { ClaudeSessionRunResult, SessionContextSnapshot, SessionMode } from '../types/contracts.js';
|
|
3
|
+
import type { ContextTracker } from './context-tracker.js';
|
|
4
|
+
export declare class SessionController {
|
|
5
|
+
private readonly sdkAdapter;
|
|
6
|
+
private readonly contextTracker;
|
|
7
|
+
private readonly sessionPrompt;
|
|
8
|
+
private readonly modePrefixes;
|
|
9
|
+
private activeSessionId;
|
|
10
|
+
constructor(sdkAdapter: ClaudeAgentSdkAdapter, contextTracker: ContextTracker, sessionPrompt: string, modePrefixes?: {
|
|
11
|
+
plan: string;
|
|
12
|
+
free: string;
|
|
13
|
+
});
|
|
14
|
+
get isActive(): boolean;
|
|
15
|
+
get sessionId(): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Send a message to the persistent session. Creates one if none exists.
|
|
18
|
+
* Returns the session result including usage data.
|
|
19
|
+
*/
|
|
20
|
+
sendMessage(cwd: string, message: string, options?: {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
23
|
+
mode?: SessionMode;
|
|
24
|
+
settingSources?: Array<'user' | 'project' | 'local'>;
|
|
25
|
+
abortSignal?: AbortSignal;
|
|
26
|
+
}, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Send /compact to the current session to compress context.
|
|
29
|
+
*/
|
|
30
|
+
compactSession(cwd: string, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Clear the current session. The next sendMessage will create a fresh one.
|
|
33
|
+
*/
|
|
34
|
+
clearSession(cwd: string): Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Get current context tracking snapshot.
|
|
37
|
+
*/
|
|
38
|
+
getContextSnapshot(): SessionContextSnapshot;
|
|
39
|
+
/**
|
|
40
|
+
* Try to restore active session from persisted state on startup.
|
|
41
|
+
*/
|
|
42
|
+
tryRestore(cwd: string): Promise<boolean>;
|
|
43
|
+
private persistActiveSession;
|
|
44
|
+
private removeActiveSession;
|
|
45
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const ACTIVE_SESSION_FILE = '.claude-manager/active-session.json';
|
|
4
|
+
export class SessionController {
|
|
5
|
+
sdkAdapter;
|
|
6
|
+
contextTracker;
|
|
7
|
+
sessionPrompt;
|
|
8
|
+
modePrefixes;
|
|
9
|
+
activeSessionId = null;
|
|
10
|
+
constructor(sdkAdapter, contextTracker, sessionPrompt, modePrefixes = {
|
|
11
|
+
plan: '',
|
|
12
|
+
free: '',
|
|
13
|
+
}) {
|
|
14
|
+
this.sdkAdapter = sdkAdapter;
|
|
15
|
+
this.contextTracker = contextTracker;
|
|
16
|
+
this.sessionPrompt = sessionPrompt;
|
|
17
|
+
this.modePrefixes = modePrefixes;
|
|
18
|
+
}
|
|
19
|
+
get isActive() {
|
|
20
|
+
return this.activeSessionId !== null;
|
|
21
|
+
}
|
|
22
|
+
get sessionId() {
|
|
23
|
+
return this.activeSessionId;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Send a message to the persistent session. Creates one if none exists.
|
|
27
|
+
* Returns the session result including usage data.
|
|
28
|
+
*/
|
|
29
|
+
async sendMessage(cwd, message, options, onEvent) {
|
|
30
|
+
const mode = options?.mode ?? 'free';
|
|
31
|
+
const prefix = this.modePrefixes[mode];
|
|
32
|
+
const prompt = prefix ? `${prefix}\n\n${message}` : message;
|
|
33
|
+
const input = {
|
|
34
|
+
cwd,
|
|
35
|
+
prompt,
|
|
36
|
+
persistSession: true,
|
|
37
|
+
permissionMode: mode === 'plan' ? 'plan' : 'acceptEdits',
|
|
38
|
+
includePartialMessages: true,
|
|
39
|
+
model: options?.model,
|
|
40
|
+
effort: options?.effort,
|
|
41
|
+
settingSources: options?.settingSources ?? ['user', 'project', 'local'],
|
|
42
|
+
abortSignal: options?.abortSignal,
|
|
43
|
+
};
|
|
44
|
+
if (this.activeSessionId) {
|
|
45
|
+
// Resume existing session
|
|
46
|
+
input.resumeSessionId = this.activeSessionId;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// New session — apply the expert operator system prompt and defaults
|
|
50
|
+
input.systemPrompt = this.sessionPrompt;
|
|
51
|
+
input.model ??= 'claude-opus-4-6';
|
|
52
|
+
input.effort ??= 'high';
|
|
53
|
+
}
|
|
54
|
+
const result = await this.sdkAdapter.runSession(input, onEvent);
|
|
55
|
+
// Track the session ID
|
|
56
|
+
if (result.sessionId) {
|
|
57
|
+
this.activeSessionId = result.sessionId;
|
|
58
|
+
}
|
|
59
|
+
// Update context tracking
|
|
60
|
+
this.contextTracker.recordResult({
|
|
61
|
+
sessionId: result.sessionId,
|
|
62
|
+
turns: result.turns,
|
|
63
|
+
totalCostUsd: result.totalCostUsd,
|
|
64
|
+
inputTokens: result.inputTokens,
|
|
65
|
+
outputTokens: result.outputTokens,
|
|
66
|
+
contextWindowSize: result.contextWindowSize,
|
|
67
|
+
});
|
|
68
|
+
// Persist active session state
|
|
69
|
+
await this.persistActiveSession(cwd);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Send /compact to the current session to compress context.
|
|
74
|
+
*/
|
|
75
|
+
async compactSession(cwd, onEvent) {
|
|
76
|
+
if (!this.activeSessionId) {
|
|
77
|
+
throw new Error('No active session to compact');
|
|
78
|
+
}
|
|
79
|
+
const result = await this.sendMessage(cwd, '/compact', undefined, onEvent);
|
|
80
|
+
this.contextTracker.recordCompaction();
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Clear the current session. The next sendMessage will create a fresh one.
|
|
85
|
+
*/
|
|
86
|
+
async clearSession(cwd) {
|
|
87
|
+
const clearedId = this.activeSessionId;
|
|
88
|
+
this.activeSessionId = null;
|
|
89
|
+
this.contextTracker.reset();
|
|
90
|
+
await this.removeActiveSession(cwd);
|
|
91
|
+
return clearedId;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get current context tracking snapshot.
|
|
95
|
+
*/
|
|
96
|
+
getContextSnapshot() {
|
|
97
|
+
return this.contextTracker.snapshot();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Try to restore active session from persisted state on startup.
|
|
101
|
+
*/
|
|
102
|
+
async tryRestore(cwd) {
|
|
103
|
+
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
104
|
+
try {
|
|
105
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
106
|
+
const state = JSON.parse(raw);
|
|
107
|
+
if (state.sessionId && state.cwd === cwd) {
|
|
108
|
+
this.activeSessionId = state.sessionId;
|
|
109
|
+
this.contextTracker.restore(state);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// File doesn't exist or is corrupt — start fresh
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async persistActiveSession(cwd) {
|
|
119
|
+
if (!this.activeSessionId) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const snap = this.contextTracker.snapshot();
|
|
123
|
+
const state = {
|
|
124
|
+
sessionId: this.activeSessionId,
|
|
125
|
+
cwd,
|
|
126
|
+
startedAt: new Date().toISOString(),
|
|
127
|
+
totalTurns: snap.totalTurns,
|
|
128
|
+
totalCostUsd: snap.totalCostUsd,
|
|
129
|
+
estimatedContextPercent: snap.estimatedContextPercent,
|
|
130
|
+
contextWindowSize: snap.contextWindowSize,
|
|
131
|
+
latestInputTokens: snap.latestInputTokens,
|
|
132
|
+
};
|
|
133
|
+
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
134
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
135
|
+
await writeFile(filePath, JSON.stringify(state, null, 2));
|
|
136
|
+
}
|
|
137
|
+
async removeActiveSession(cwd) {
|
|
138
|
+
const filePath = join(cwd, ACTIVE_SESSION_FILE);
|
|
139
|
+
try {
|
|
140
|
+
const { unlink } = await import('node:fs/promises');
|
|
141
|
+
await unlink(filePath);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// File doesn't exist — that's fine
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ClaudeMetadataSnapshot, ClaudeSettingSource } from '../types/contracts.js';
|
|
2
|
+
import type { ClaudeAgentSdkAdapter } from '../claude/claude-agent-sdk-adapter.js';
|
|
3
|
+
import type { RepoClaudeConfigReader } from './repo-claude-config-reader.js';
|
|
4
|
+
export declare class ClaudeMetadataService {
|
|
5
|
+
private readonly configReader;
|
|
6
|
+
private readonly sdkAdapter;
|
|
7
|
+
constructor(configReader: RepoClaudeConfigReader, sdkAdapter: ClaudeAgentSdkAdapter);
|
|
8
|
+
collect(cwd: string, options?: {
|
|
9
|
+
includeSdkProbe?: boolean;
|
|
10
|
+
settingSources?: ClaudeSettingSource[];
|
|
11
|
+
}): Promise<ClaudeMetadataSnapshot>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export class ClaudeMetadataService {
|
|
2
|
+
configReader;
|
|
3
|
+
sdkAdapter;
|
|
4
|
+
constructor(configReader, sdkAdapter) {
|
|
5
|
+
this.configReader = configReader;
|
|
6
|
+
this.sdkAdapter = sdkAdapter;
|
|
7
|
+
}
|
|
8
|
+
async collect(cwd, options = {}) {
|
|
9
|
+
const baseSnapshot = await this.configReader.read(cwd);
|
|
10
|
+
if (!options.includeSdkProbe) {
|
|
11
|
+
return dedupeSnapshot(baseSnapshot);
|
|
12
|
+
}
|
|
13
|
+
const capabilities = await this.sdkAdapter.probeCapabilities(cwd, options.settingSources);
|
|
14
|
+
return dedupeSnapshot({
|
|
15
|
+
...baseSnapshot,
|
|
16
|
+
commands: [...baseSnapshot.commands, ...capabilities.commands],
|
|
17
|
+
agents: capabilities.agents,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function dedupeSnapshot(snapshot) {
|
|
22
|
+
return {
|
|
23
|
+
...snapshot,
|
|
24
|
+
commands: dedupeByName(snapshot.commands),
|
|
25
|
+
skills: dedupeByName(snapshot.skills),
|
|
26
|
+
hooks: dedupeByName(snapshot.hooks),
|
|
27
|
+
agents: dedupeByName(snapshot.agents),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function dedupeByName(items) {
|
|
31
|
+
const seen = new Map();
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
if (!seen.has(item.name)) {
|
|
34
|
+
seen.set(item.name, item);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...seen.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
38
|
+
}
|