@just-every/manager 0.2.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/chunk-KD5PYPXI.js +1277 -0
- package/dist/chunk-ZQKKKD3J.js +38 -0
- package/dist/cli.js +593 -0
- package/dist/daemon-DTD73K4H.js +7 -0
- package/dist/managerd.js +11 -0
- package/package.json +39 -0
- package/src/cli.ts +483 -0
- package/src/connectors/claude.ts +5 -0
- package/src/connectors/codex.ts +135 -0
- package/src/connectors/gemini.ts +106 -0
- package/src/constants.ts +32 -0
- package/src/daemon/http-server.ts +234 -0
- package/src/daemon/index.ts +61 -0
- package/src/ingestion/claude-ingestor.ts +170 -0
- package/src/ingestion/codex-ingestor.test.ts +94 -0
- package/src/ingestion/codex-ingestor.ts +161 -0
- package/src/ingestion/gemini-ingestor.ts +223 -0
- package/src/integrations/install.ts +38 -0
- package/src/integrations/sync.ts +173 -0
- package/src/managerd.ts +7 -0
- package/src/storage/event-store.ts +153 -0
- package/src/storage/session-store.test.ts +34 -0
- package/src/storage/session-store.ts +231 -0
- package/src/utils/fs.ts +19 -0
- package/src/utils/logger.ts +13 -0
- package/src/watchers/gemini-log-watcher.ts +141 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { SessionStore } from '../storage/session-store.js';
|
|
5
|
+
import { SessionStatus } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
export interface GeminiLaunchRequest {
|
|
8
|
+
command?: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
autoRestart?: boolean;
|
|
11
|
+
maxRestarts?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiLaunchResponse {
|
|
15
|
+
localSessionId: string;
|
|
16
|
+
toolSessionId: string;
|
|
17
|
+
command: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ProcessState = {
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
autoRestart: boolean;
|
|
26
|
+
maxRestarts: number;
|
|
27
|
+
restarts: number;
|
|
28
|
+
child?: ReturnType<typeof spawn>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class GeminiConnector {
|
|
32
|
+
#store: SessionStore;
|
|
33
|
+
#sessions = new Map<string, ProcessState>();
|
|
34
|
+
|
|
35
|
+
constructor(store: SessionStore) {
|
|
36
|
+
this.#store = store;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async launch(request: GeminiLaunchRequest = {}): Promise<GeminiLaunchResponse> {
|
|
40
|
+
const command = request.command ?? process.env.MANAGER_GEMINI_COMMAND ?? 'gemini';
|
|
41
|
+
const args = request.args ?? [];
|
|
42
|
+
const session = this.#store.createSession('gemini', command, args);
|
|
43
|
+
const sessionLogDir = process.env.MANAGER_GEMINI_LOG_DIR ?? path.join(process.env.HOME || '', '.gemini', 'sessions');
|
|
44
|
+
await fs.mkdir(sessionLogDir, { recursive: true });
|
|
45
|
+
const env = {
|
|
46
|
+
...process.env,
|
|
47
|
+
MANAGER_SESSION_ID: session.id,
|
|
48
|
+
MANAGER_GEMINI_SESSION_ID: session.toolSessionId,
|
|
49
|
+
GEMINI_SESSION_LOG: path.join(sessionLogDir, `${session.toolSessionId}.jsonl`),
|
|
50
|
+
};
|
|
51
|
+
const state: ProcessState = {
|
|
52
|
+
command,
|
|
53
|
+
args,
|
|
54
|
+
env,
|
|
55
|
+
autoRestart: request.autoRestart !== false,
|
|
56
|
+
maxRestarts: request.maxRestarts ?? 3,
|
|
57
|
+
restarts: 0,
|
|
58
|
+
};
|
|
59
|
+
this.#sessions.set(session.id, state);
|
|
60
|
+
this.spawnProcess(session.id, state);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
localSessionId: session.id,
|
|
64
|
+
toolSessionId: session.toolSessionId,
|
|
65
|
+
command,
|
|
66
|
+
args,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private spawnProcess(sessionId: string, state: ProcessState) {
|
|
71
|
+
const child = spawn(state.command, state.args, {
|
|
72
|
+
env: state.env,
|
|
73
|
+
stdio: 'ignore',
|
|
74
|
+
});
|
|
75
|
+
state.child = child;
|
|
76
|
+
this.#store.updateStatus(sessionId, SessionStatus.Running);
|
|
77
|
+
|
|
78
|
+
child.once('exit', (code) => this.handleExit(sessionId, state, code));
|
|
79
|
+
child.once('error', (err) => {
|
|
80
|
+
console.error('[gemini] failed to launch', err);
|
|
81
|
+
this.handleExit(sessionId, state, -1);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private handleExit(sessionId: string, state: ProcessState, code: number | null) {
|
|
86
|
+
const exitCode = code ?? -1;
|
|
87
|
+
const successful = exitCode === 0;
|
|
88
|
+
this.#store.updateStatus(sessionId, successful ? SessionStatus.Completed : SessionStatus.Failed, exitCode);
|
|
89
|
+
if (state.autoRestart && !successful && state.restarts < state.maxRestarts) {
|
|
90
|
+
state.restarts += 1;
|
|
91
|
+
const delay = Math.min(1000 * state.restarts, 5000);
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
console.warn(`[gemini] restarting session ${sessionId} attempt ${state.restarts}`);
|
|
94
|
+
this.spawnProcess(sessionId, state);
|
|
95
|
+
}, delay);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.#sessions.delete(sessionId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function describeGeminiConnector(): string {
|
|
103
|
+
const command = process.env.MANAGER_GEMINI_COMMAND ?? 'gemini';
|
|
104
|
+
const logDir = process.env.MANAGER_GEMINI_LOG_DIR ?? path.join(process.env.HOME || '', '.gemini', 'sessions');
|
|
105
|
+
return `command=${command} logDir=${logDir}`;
|
|
106
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
5
|
+
export const DEFAULT_PORT = Number(process.env.MANAGERD_PORT ?? 7765);
|
|
6
|
+
|
|
7
|
+
export const MANAGERD_HOME = (() => {
|
|
8
|
+
if (process.env.MANAGERD_HOME) {
|
|
9
|
+
return path.resolve(process.env.MANAGERD_HOME);
|
|
10
|
+
}
|
|
11
|
+
return path.join(os.homedir(), '.managerd');
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
export const DATA_DIRS = {
|
|
15
|
+
root: MANAGERD_HOME,
|
|
16
|
+
logs: path.join(MANAGERD_HOME, 'logs'),
|
|
17
|
+
sessions: path.join(MANAGERD_HOME, 'sessions'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const DB_PATH = path.join(MANAGERD_HOME, 'state.db');
|
|
21
|
+
export const PID_FILE = path.join(MANAGERD_HOME, 'managerd.pid');
|
|
22
|
+
export const DEFAULT_CODEX_HISTORY = path.join(os.homedir(), '.codex', 'sessions');
|
|
23
|
+
export const DEFAULT_CODEX_TRANSCRIPT = 'output.json';
|
|
24
|
+
|
|
25
|
+
export enum SessionStatus {
|
|
26
|
+
Launching = 'launching',
|
|
27
|
+
Running = 'running',
|
|
28
|
+
Failed = 'failed',
|
|
29
|
+
Completed = 'completed',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AgentType = 'codex' | 'claude' | 'gemini';
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { DEFAULT_HOST, DEFAULT_PORT, SessionStatus } from '../constants.js';
|
|
4
|
+
import { SessionStore, createSessionStore } from '../storage/session-store.js';
|
|
5
|
+
import { CodexConnector, describeCodexConnector } from '../connectors/codex.js';
|
|
6
|
+
import { GeminiConnector, describeGeminiConnector } from '../connectors/gemini.js';
|
|
7
|
+
import { EventStore, createEventStore } from '../storage/event-store.js';
|
|
8
|
+
import { CodexIngestor } from '../ingestion/codex-ingestor.js';
|
|
9
|
+
import { ClaudeIngestor, ClaudeHookEvent } from '../ingestion/claude-ingestor.js';
|
|
10
|
+
import { GeminiIngestor, GeminiSessionPayload } from '../ingestion/gemini-ingestor.js';
|
|
11
|
+
import { GeminiLogWatcher } from '../watchers/gemini-log-watcher.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
export interface ManagerDaemonOptions {
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ManagerDaemon {
|
|
20
|
+
#store: SessionStore;
|
|
21
|
+
#codex: CodexConnector;
|
|
22
|
+
#gemini: GeminiConnector;
|
|
23
|
+
#events: EventStore;
|
|
24
|
+
#codexIngestor: CodexIngestor;
|
|
25
|
+
#claudeIngestor: ClaudeIngestor;
|
|
26
|
+
#geminiIngestor: GeminiIngestor;
|
|
27
|
+
#geminiWatcher: GeminiLogWatcher;
|
|
28
|
+
#server?: http.Server;
|
|
29
|
+
#startedAt = Date.now();
|
|
30
|
+
#host: string;
|
|
31
|
+
#port: number;
|
|
32
|
+
|
|
33
|
+
constructor(store: SessionStore = createSessionStore(), options: ManagerDaemonOptions = {}) {
|
|
34
|
+
this.#store = store;
|
|
35
|
+
this.#codex = new CodexConnector(this.#store);
|
|
36
|
+
this.#gemini = new GeminiConnector(this.#store);
|
|
37
|
+
this.#events = createEventStore();
|
|
38
|
+
this.#codexIngestor = new CodexIngestor(this.#events);
|
|
39
|
+
this.#claudeIngestor = new ClaudeIngestor(this.#store, this.#events);
|
|
40
|
+
this.#geminiIngestor = new GeminiIngestor(this.#events);
|
|
41
|
+
this.#geminiWatcher = new GeminiLogWatcher(this.#store, this.#geminiIngestor);
|
|
42
|
+
this.#host = options.host ?? DEFAULT_HOST;
|
|
43
|
+
this.#port = options.port ?? DEFAULT_PORT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
if (this.#server) return;
|
|
48
|
+
this.#server = http.createServer((req, res) => {
|
|
49
|
+
this.route(req, res).catch((err) => {
|
|
50
|
+
logger.error({ err }, 'unhandled daemon error');
|
|
51
|
+
this.writeJson(res, 500, { ok: false, error: 'internal_error' });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await new Promise<void>((resolve) => {
|
|
56
|
+
this.#server!.listen(this.#port, this.#host, () => {
|
|
57
|
+
logger.info({ host: this.#host, port: this.#port }, 'managerd listening');
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
this.#geminiWatcher.start();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async stop(): Promise<void> {
|
|
65
|
+
this.#geminiWatcher.stop();
|
|
66
|
+
if (!this.#server) return;
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
this.#server!.close((err) => (err ? reject(err) : resolve()));
|
|
69
|
+
});
|
|
70
|
+
this.#server = undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async route(req: IncomingMessage, res: ServerResponse) {
|
|
74
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
75
|
+
|
|
76
|
+
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
77
|
+
return this.writeJson(res, 200, { ok: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
81
|
+
const sessions = this.#store.listRecent(5);
|
|
82
|
+
return this.writeJson(res, 200, {
|
|
83
|
+
ok: true,
|
|
84
|
+
uptimeMs: Date.now() - this.#startedAt,
|
|
85
|
+
codex: describeCodexConnector(),
|
|
86
|
+
gemini: describeGeminiConnector(),
|
|
87
|
+
runningSessions: sessions.filter((s) => s.status === SessionStatus.Running).length,
|
|
88
|
+
sessions,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (req.method === 'GET' && url.pathname === '/sessions') {
|
|
93
|
+
return this.writeJson(res, 200, { ok: true, sessions: this.#store.listRecent(25) });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (req.method === 'POST' && url.pathname === '/rpc/launch') {
|
|
97
|
+
const body = await this.parseJson(req);
|
|
98
|
+
const agent = body?.agent ?? 'codex';
|
|
99
|
+
if (agent === 'codex') {
|
|
100
|
+
const result = await this.#codex.launch({
|
|
101
|
+
command: body?.command,
|
|
102
|
+
args: body?.args,
|
|
103
|
+
disableManagedFlags: body?.disableManagedFlags,
|
|
104
|
+
autoRestart: body?.autoRestart !== false,
|
|
105
|
+
maxRestarts: body?.maxRestarts ?? 3,
|
|
106
|
+
});
|
|
107
|
+
return this.writeJson(res, 200, { ok: true, result });
|
|
108
|
+
}
|
|
109
|
+
if (agent === 'gemini') {
|
|
110
|
+
const result = await this.#gemini.launch({
|
|
111
|
+
command: body?.command,
|
|
112
|
+
args: body?.args,
|
|
113
|
+
autoRestart: body?.autoRestart !== false,
|
|
114
|
+
maxRestarts: body?.maxRestarts ?? 3,
|
|
115
|
+
});
|
|
116
|
+
return this.writeJson(res, 200, { ok: true, result });
|
|
117
|
+
}
|
|
118
|
+
return this.writeJson(res, 400, { ok: false, error: 'unsupported_agent' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (req.method === 'POST' && url.pathname === '/hooks/claude') {
|
|
122
|
+
return this.handleClaudeHook(req, res);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (req.method === 'POST' && url.pathname === '/rpc/gemini/ingest') {
|
|
126
|
+
return this.handleGeminiIngest(req, res);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (req.method === 'POST' && url.pathname === '/rpc/sessions/events') {
|
|
130
|
+
const body = await this.parseJson(req);
|
|
131
|
+
const sessionId = body?.sessionId;
|
|
132
|
+
const limit = Number(body?.limit ?? 20);
|
|
133
|
+
if (!sessionId) {
|
|
134
|
+
return this.writeJson(res, 400, { ok: false, error: 'missing_session_id' });
|
|
135
|
+
}
|
|
136
|
+
const session = this.#store.get(sessionId);
|
|
137
|
+
if (!session) {
|
|
138
|
+
return this.writeJson(res, 404, { ok: false, error: 'session_not_found' });
|
|
139
|
+
}
|
|
140
|
+
let warnings: string[] = [];
|
|
141
|
+
try {
|
|
142
|
+
const result = await this.#codexIngestor.ingest(session);
|
|
143
|
+
warnings = result.warnings;
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
warnings.push(`Ingestion failed: ${err.message}`);
|
|
146
|
+
}
|
|
147
|
+
const events = this.#events.listBySession(sessionId, limit);
|
|
148
|
+
return this.writeJson(res, 200, { ok: true, session, events, warnings });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.writeJson(res, 404, { ok: false, error: 'not_found' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async handleClaudeHook(_req: IncomingMessage, res: ServerResponse) {
|
|
155
|
+
const body = await this.parseJson(_req);
|
|
156
|
+
const incoming = Array.isArray(body?.events) ? body.events : [body];
|
|
157
|
+
if (!incoming.length) {
|
|
158
|
+
return this.writeJson(res, 400, { ok: false, error: 'no_events' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const results = incoming.map((raw) => {
|
|
162
|
+
if (!raw || typeof raw !== 'object') {
|
|
163
|
+
return { ok: false, error: 'invalid_event' };
|
|
164
|
+
}
|
|
165
|
+
const eventName = raw.event ?? raw.type;
|
|
166
|
+
const sessionId = raw.sessionId ?? raw.session_id;
|
|
167
|
+
if (!eventName || !sessionId) {
|
|
168
|
+
return { ok: false, error: 'missing_fields', sessionId };
|
|
169
|
+
}
|
|
170
|
+
const hookEvent: ClaudeHookEvent = {
|
|
171
|
+
event: String(eventName),
|
|
172
|
+
sessionId: String(sessionId),
|
|
173
|
+
timestamp: raw.timestamp,
|
|
174
|
+
payload: raw.payload ?? raw.data ?? raw,
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
const result = this.#claudeIngestor.ingest(hookEvent);
|
|
178
|
+
return { ok: true, sessionId: hookEvent.sessionId, inserted: result.inserted };
|
|
179
|
+
} catch (err: any) {
|
|
180
|
+
logger.error({ err }, 'claude hook ingestion failed');
|
|
181
|
+
return { ok: false, sessionId: hookEvent.sessionId, error: err?.message ?? 'ingest_failed' };
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const ok = results.every((item) => item.ok);
|
|
186
|
+
return this.writeJson(res, ok ? 202 : 207, { ok, results });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async handleGeminiIngest(req: IncomingMessage, res: ServerResponse) {
|
|
190
|
+
const body = await this.parseJson(req);
|
|
191
|
+
const data: GeminiSessionPayload | undefined = body?.data;
|
|
192
|
+
const fileHint: string | undefined = body?.filePath;
|
|
193
|
+
if (!data || typeof data !== 'object') {
|
|
194
|
+
return this.writeJson(res, 400, { ok: false, error: 'missing_payload' });
|
|
195
|
+
}
|
|
196
|
+
const providedId = body?.sessionId ?? data.sessionId ?? data['id'];
|
|
197
|
+
const sessionId = (providedId || fileHint || `gemini-${Date.now()}`).toString();
|
|
198
|
+
let session = this.#store.findByToolSession('gemini', sessionId);
|
|
199
|
+
if (!session) {
|
|
200
|
+
session = this.#store.createSession('gemini', 'gemini-cli', [], { toolSessionId: sessionId });
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const result = this.#geminiIngestor.ingest(session, data);
|
|
204
|
+
return this.writeJson(res, 200, { ok: true, inserted: result.inserted, warnings: result.warnings });
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
logger.error({ err }, 'gemini ingest failed');
|
|
207
|
+
return this.writeJson(res, 500, { ok: false, error: err?.message ?? 'ingest_failed' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async parseJson(req: IncomingMessage): Promise<any> {
|
|
212
|
+
const chunks: Buffer[] = [];
|
|
213
|
+
for await (const chunk of req) {
|
|
214
|
+
chunks.push(chunk as Buffer);
|
|
215
|
+
}
|
|
216
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
217
|
+
if (!raw) return {};
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(raw);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger.warn({ err }, 'failed to parse json');
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private writeJson(res: ServerResponse, status: number, payload: any) {
|
|
227
|
+
const body = JSON.stringify(payload);
|
|
228
|
+
res.writeHead(status, {
|
|
229
|
+
'content-type': 'application/json',
|
|
230
|
+
'content-length': Buffer.byteLength(body),
|
|
231
|
+
});
|
|
232
|
+
res.end(body);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { ManagerDaemon } from './http-server.js';
|
|
4
|
+
import { ensureBaseDirs } from '../storage/session-store.js';
|
|
5
|
+
import { DATA_DIRS, PID_FILE } from '../constants.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
export async function startDaemon() {
|
|
9
|
+
await ensureBaseDirs(DATA_DIRS.root);
|
|
10
|
+
await ensurePidAvailable();
|
|
11
|
+
const daemon = new ManagerDaemon();
|
|
12
|
+
let cleanedUp = false;
|
|
13
|
+
const cleanup = async () => {
|
|
14
|
+
if (cleanedUp) return;
|
|
15
|
+
cleanedUp = true;
|
|
16
|
+
await daemon.stop().catch(() => {});
|
|
17
|
+
await removePidFile();
|
|
18
|
+
};
|
|
19
|
+
process.on('SIGINT', async () => {
|
|
20
|
+
logger.info('received SIGINT, shutting down');
|
|
21
|
+
await cleanup();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
});
|
|
24
|
+
process.on('SIGTERM', async () => {
|
|
25
|
+
logger.info('received SIGTERM, shutting down');
|
|
26
|
+
await cleanup();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
process.on('exit', () => {
|
|
30
|
+
void cleanup();
|
|
31
|
+
});
|
|
32
|
+
await daemon.start();
|
|
33
|
+
await fs.writeFile(PID_FILE, String(process.pid), 'utf8');
|
|
34
|
+
logger.info('managerd is running. Press Ctrl+C to exit.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ensurePidAvailable() {
|
|
38
|
+
try {
|
|
39
|
+
const contents = await fs.readFile(PID_FILE, 'utf8');
|
|
40
|
+
const pid = Number(contents.trim());
|
|
41
|
+
if (Number.isFinite(pid) && isProcessAlive(pid)) {
|
|
42
|
+
throw new Error(`managerd already running (pid ${pid})`);
|
|
43
|
+
}
|
|
44
|
+
await removePidFile();
|
|
45
|
+
} catch {
|
|
46
|
+
// no-op
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function removePidFile() {
|
|
51
|
+
await fs.unlink(PID_FILE).catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isProcessAlive(pid: number): boolean {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { SessionRecord, SessionStore } from '../storage/session-store.js';
|
|
2
|
+
import { EventStore, AgentEventRecord } from '../storage/event-store.js';
|
|
3
|
+
import { SessionStatus } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
export interface ClaudeHookEvent {
|
|
6
|
+
event: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
timestamp?: string;
|
|
9
|
+
payload?: Record<string, any>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClaudeIngestResult {
|
|
13
|
+
inserted: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ClaudeIngestor {
|
|
17
|
+
constructor(private store: SessionStore, private events: EventStore) {}
|
|
18
|
+
|
|
19
|
+
ensureSession(sessionId: string): SessionRecord {
|
|
20
|
+
const existing = this.store.findByToolSession('claude', sessionId);
|
|
21
|
+
if (existing) {
|
|
22
|
+
return existing;
|
|
23
|
+
}
|
|
24
|
+
return this.store.createSession('claude', 'claude-code', [], { toolSessionId: sessionId });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ingest(hook: ClaudeHookEvent): ClaudeIngestResult {
|
|
28
|
+
const session = this.ensureSession(hook.sessionId);
|
|
29
|
+
const timestamp = parseTimestamp(hook.timestamp);
|
|
30
|
+
const baseSequence = this.events.maxSequence(session.id);
|
|
31
|
+
const record = mapClaudeHookToEvent(session.id, baseSequence + 1, hook, timestamp);
|
|
32
|
+
if (!record) {
|
|
33
|
+
return { inserted: 0 };
|
|
34
|
+
}
|
|
35
|
+
this.events.insert([record]);
|
|
36
|
+
if (record.eventType === 'session_start') {
|
|
37
|
+
this.store.updateStatus(session.id, SessionStatus.Running);
|
|
38
|
+
}
|
|
39
|
+
if (record.eventType === 'session_end') {
|
|
40
|
+
this.store.updateStatus(session.id, SessionStatus.Completed);
|
|
41
|
+
}
|
|
42
|
+
return { inserted: 1 };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseTimestamp(value?: string): number {
|
|
47
|
+
if (!value) return Date.now();
|
|
48
|
+
const ms = Date.parse(value);
|
|
49
|
+
return Number.isNaN(ms) ? Date.now() : ms;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mapClaudeHookToEvent(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
sequence: number,
|
|
55
|
+
hook: ClaudeHookEvent,
|
|
56
|
+
timestamp: number,
|
|
57
|
+
): AgentEventRecord | null {
|
|
58
|
+
const payload = hook.payload ?? {};
|
|
59
|
+
const type = (hook.event || '').toLowerCase();
|
|
60
|
+
switch (type) {
|
|
61
|
+
case 'sessionstart':
|
|
62
|
+
return {
|
|
63
|
+
id: buildEventId(sessionId, sequence),
|
|
64
|
+
sessionId,
|
|
65
|
+
eventType: 'session_start',
|
|
66
|
+
sequence,
|
|
67
|
+
timestamp,
|
|
68
|
+
role: 'system',
|
|
69
|
+
text: payload.summary ?? null,
|
|
70
|
+
rawPayload: JSON.stringify(hook),
|
|
71
|
+
sourceFile: 'claude-hook',
|
|
72
|
+
};
|
|
73
|
+
case 'sessionend':
|
|
74
|
+
return {
|
|
75
|
+
id: buildEventId(sessionId, sequence),
|
|
76
|
+
sessionId,
|
|
77
|
+
eventType: 'session_end',
|
|
78
|
+
sequence,
|
|
79
|
+
timestamp,
|
|
80
|
+
role: 'system',
|
|
81
|
+
text: payload.summary ?? 'Session ended',
|
|
82
|
+
rawPayload: JSON.stringify(hook),
|
|
83
|
+
sourceFile: 'claude-hook',
|
|
84
|
+
};
|
|
85
|
+
case 'userpromptsubmit':
|
|
86
|
+
case 'userprompt':
|
|
87
|
+
return {
|
|
88
|
+
id: buildEventId(sessionId, sequence),
|
|
89
|
+
sessionId,
|
|
90
|
+
eventType: 'user_prompt',
|
|
91
|
+
sequence,
|
|
92
|
+
timestamp,
|
|
93
|
+
role: 'user',
|
|
94
|
+
text: payload.prompt ?? payload.text ?? '',
|
|
95
|
+
rawPayload: JSON.stringify(hook),
|
|
96
|
+
sourceFile: 'claude-hook',
|
|
97
|
+
};
|
|
98
|
+
case 'pretooluse':
|
|
99
|
+
return {
|
|
100
|
+
id: buildEventId(sessionId, sequence),
|
|
101
|
+
sessionId,
|
|
102
|
+
eventType: 'tool_use',
|
|
103
|
+
sequence,
|
|
104
|
+
timestamp,
|
|
105
|
+
role: 'assistant',
|
|
106
|
+
text: payload.commentary ?? null,
|
|
107
|
+
toolName: payload.toolName ?? payload.tool?.name ?? 'unknown_tool',
|
|
108
|
+
toolInput: safeStringify(payload.input ?? payload.toolInput),
|
|
109
|
+
rawPayload: JSON.stringify(hook),
|
|
110
|
+
sourceFile: 'claude-hook',
|
|
111
|
+
};
|
|
112
|
+
case 'posttooluse':
|
|
113
|
+
return {
|
|
114
|
+
id: buildEventId(sessionId, sequence),
|
|
115
|
+
sessionId,
|
|
116
|
+
eventType: 'tool_result',
|
|
117
|
+
sequence,
|
|
118
|
+
timestamp,
|
|
119
|
+
role: 'assistant',
|
|
120
|
+
text: payload.commentary ?? null,
|
|
121
|
+
toolName: payload.toolName ?? payload.tool?.name ?? 'unknown_tool',
|
|
122
|
+
toolOutput: safeStringify(payload.result ?? payload.toolOutput ?? payload.output),
|
|
123
|
+
errorType: payload.error?.type ?? null,
|
|
124
|
+
errorMessage: payload.error?.message ?? null,
|
|
125
|
+
rawPayload: JSON.stringify(hook),
|
|
126
|
+
sourceFile: 'claude-hook',
|
|
127
|
+
};
|
|
128
|
+
case 'permissionrequest':
|
|
129
|
+
return {
|
|
130
|
+
id: buildEventId(sessionId, sequence),
|
|
131
|
+
sessionId,
|
|
132
|
+
eventType: 'permission_request',
|
|
133
|
+
sequence,
|
|
134
|
+
timestamp,
|
|
135
|
+
role: 'assistant',
|
|
136
|
+
text: payload.reason ?? payload.prompt ?? 'Permission requested',
|
|
137
|
+
toolInput: safeStringify(payload.details),
|
|
138
|
+
rawPayload: JSON.stringify(hook),
|
|
139
|
+
sourceFile: 'claude-hook',
|
|
140
|
+
};
|
|
141
|
+
case 'notification':
|
|
142
|
+
return {
|
|
143
|
+
id: buildEventId(sessionId, sequence),
|
|
144
|
+
sessionId,
|
|
145
|
+
eventType: 'notification',
|
|
146
|
+
sequence,
|
|
147
|
+
timestamp,
|
|
148
|
+
role: 'system',
|
|
149
|
+
text: payload.message ?? payload.text ?? 'Notification',
|
|
150
|
+
errorType: payload.severity ?? null,
|
|
151
|
+
rawPayload: JSON.stringify(hook),
|
|
152
|
+
sourceFile: 'claude-hook',
|
|
153
|
+
};
|
|
154
|
+
default:
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildEventId(sessionId: string, sequence: number): string {
|
|
160
|
+
return `${sessionId}:claude:${sequence}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function safeStringify(input: unknown): string | null {
|
|
164
|
+
if (input == null) return null;
|
|
165
|
+
try {
|
|
166
|
+
return JSON.stringify(input);
|
|
167
|
+
} catch {
|
|
168
|
+
return String(input);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { CodexIngestor } from './codex-ingestor.js';
|
|
6
|
+
import { createEventStore } from '../storage/event-store.js';
|
|
7
|
+
import { SessionRecord } from '../storage/session-store.js';
|
|
8
|
+
import { SessionStatus } from '../constants.js';
|
|
9
|
+
|
|
10
|
+
describe('CodexIngestor', () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let store: ReturnType<typeof createEventStore>;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-history-'));
|
|
16
|
+
process.env.MANAGER_CODEX_HISTORY = tmpDir;
|
|
17
|
+
process.env.MANAGERD_STORAGE = 'memory';
|
|
18
|
+
store = createEventStore();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
delete process.env.MANAGER_CODEX_HISTORY;
|
|
23
|
+
delete process.env.MANAGERD_STORAGE;
|
|
24
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const baseSession: SessionRecord = {
|
|
28
|
+
id: 'session-local',
|
|
29
|
+
agentType: 'codex',
|
|
30
|
+
toolSessionId: 'tool-session',
|
|
31
|
+
status: SessionStatus.Running,
|
|
32
|
+
command: 'codex',
|
|
33
|
+
args: [],
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
updatedAt: new Date().toISOString(),
|
|
36
|
+
exitCode: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it('ingests simple text messages from output.json', async () => {
|
|
40
|
+
const sessionDir = path.join(tmpDir, baseSession.toolSessionId);
|
|
41
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
42
|
+
await fs.writeFile(
|
|
43
|
+
path.join(sessionDir, 'output.json'),
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
messages: [
|
|
46
|
+
{ role: 'user', content: 'hello' },
|
|
47
|
+
{ role: 'assistant', content: 'hi there' },
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const ingestor = new CodexIngestor(store);
|
|
53
|
+
const result = await ingestor.ingest(baseSession);
|
|
54
|
+
|
|
55
|
+
expect(result.events).toBe(2);
|
|
56
|
+
const events = store.listBySession(baseSession.id, 10);
|
|
57
|
+
expect(events).toHaveLength(2);
|
|
58
|
+
expect(events[1].eventType).toBe('message');
|
|
59
|
+
expect(events[1].text).toBe('hello');
|
|
60
|
+
expect(events[0].text).toBe('hi there');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ingests structured tool use blocks', async () => {
|
|
64
|
+
const sessionDir = path.join(tmpDir, baseSession.toolSessionId);
|
|
65
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
66
|
+
await fs.writeFile(
|
|
67
|
+
path.join(sessionDir, 'output.json'),
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
messages: [
|
|
70
|
+
{
|
|
71
|
+
role: 'assistant',
|
|
72
|
+
content: [
|
|
73
|
+
{ type: 'text', text: 'running command' },
|
|
74
|
+
{ type: 'tool_use', name: 'run_cmd', input: { cmd: 'ls' } },
|
|
75
|
+
{ type: 'tool_result', content: { stdout: 'README.md' } },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const ingestor = new CodexIngestor(store);
|
|
83
|
+
const result = await ingestor.ingest(baseSession);
|
|
84
|
+
|
|
85
|
+
expect(result.events).toBe(3);
|
|
86
|
+
const events = store.listBySession(baseSession.id, 5);
|
|
87
|
+
expect(events.map((e) => e.eventType)).toContain('tool_use');
|
|
88
|
+
const toolUse = events.find((e) => e.eventType === 'tool_use');
|
|
89
|
+
expect(toolUse?.toolName).toBe('run_cmd');
|
|
90
|
+
expect(toolUse?.toolInput).toContain('ls');
|
|
91
|
+
const toolResult = events.find((e) => e.eventType === 'tool_result');
|
|
92
|
+
expect(toolResult?.toolOutput).toContain('README');
|
|
93
|
+
});
|
|
94
|
+
});
|