@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.
@@ -0,0 +1,161 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { DEFAULT_CODEX_HISTORY, DEFAULT_CODEX_TRANSCRIPT } from '../constants.js';
4
+ import { SessionRecord } from '../storage/session-store.js';
5
+ import { EventStore, AgentEventRecord } from '../storage/event-store.js';
6
+
7
+ interface IngestResult {
8
+ events: number;
9
+ warnings: string[];
10
+ }
11
+
12
+ export class CodexIngestor {
13
+ constructor(private eventStore: EventStore) {}
14
+
15
+ async ingest(session: SessionRecord): Promise<IngestResult> {
16
+ const historyDir = process.env.MANAGER_CODEX_HISTORY ?? DEFAULT_CODEX_HISTORY;
17
+ const sessionDir = path.join(historyDir, session.toolSessionId);
18
+ const transcriptPath = path.join(
19
+ sessionDir,
20
+ process.env.MANAGER_CODEX_TRANSCRIPT ?? DEFAULT_CODEX_TRANSCRIPT,
21
+ );
22
+
23
+ const warnings: string[] = [];
24
+ const stats = await fs.stat(transcriptPath).catch(() => null);
25
+ if (!stats) {
26
+ warnings.push(`Transcript file not found at ${transcriptPath}`);
27
+ return { events: 0, warnings };
28
+ }
29
+
30
+ const content = await fs.readFile(transcriptPath, 'utf8');
31
+ let parsed: CodexTranscript;
32
+ try {
33
+ parsed = JSON.parse(content);
34
+ } catch (err: any) {
35
+ warnings.push(`Failed to parse ${transcriptPath}: ${err.message}`);
36
+ return { events: 0, warnings };
37
+ }
38
+
39
+ const existingMax = this.eventStore.maxSequence(session.id);
40
+ const sourceFile = path.basename(transcriptPath);
41
+ const records = normalizeCodexTranscript(session.id, parsed, existingMax, sourceFile);
42
+ this.eventStore.insert(records);
43
+ return { events: records.length, warnings };
44
+ }
45
+ }
46
+
47
+ interface CodexTranscript {
48
+ messages?: CodexMessage[];
49
+ }
50
+
51
+ interface CodexMessage {
52
+ role: string;
53
+ content: string | CodexContentBlock[];
54
+ timestamp?: number;
55
+ id?: string;
56
+ }
57
+
58
+ type CodexContentBlock =
59
+ | { type: 'text'; text: string }
60
+ | { type: 'tool_use'; name: string; input: unknown; id?: string }
61
+ | { type: 'tool_result'; tool_use_id?: string; content?: unknown }
62
+ | { type: 'error'; error?: { type?: string; message?: string };
63
+ };
64
+
65
+ export function normalizeCodexTranscript(
66
+ sessionId: string,
67
+ transcript: CodexTranscript,
68
+ startSequence: number,
69
+ sourceFile: string = DEFAULT_CODEX_TRANSCRIPT,
70
+ ): AgentEventRecord[] {
71
+ const records: AgentEventRecord[] = [];
72
+ let sequence = startSequence;
73
+ for (const message of transcript.messages ?? []) {
74
+ const timestamp = message.timestamp ?? Date.now();
75
+ if (typeof message.content === 'string') {
76
+ sequence += 1;
77
+ records.push({
78
+ id: buildEventId(sessionId, sequence),
79
+ sessionId,
80
+ eventType: 'message',
81
+ sequence,
82
+ timestamp,
83
+ role: message.role,
84
+ text: message.content,
85
+ rawPayload: JSON.stringify(message),
86
+ sourceFile,
87
+ });
88
+ continue;
89
+ }
90
+ for (const block of message.content ?? []) {
91
+ if (block.type === 'text') {
92
+ sequence += 1;
93
+ records.push({
94
+ id: buildEventId(sessionId, sequence),
95
+ sessionId,
96
+ eventType: 'message',
97
+ sequence,
98
+ timestamp,
99
+ role: message.role,
100
+ text: block.text,
101
+ rawPayload: JSON.stringify(block),
102
+ sourceFile,
103
+ });
104
+ } else if (block.type === 'tool_use') {
105
+ sequence += 1;
106
+ records.push({
107
+ id: buildEventId(sessionId, sequence),
108
+ sessionId,
109
+ eventType: 'tool_use',
110
+ sequence,
111
+ timestamp,
112
+ role: message.role,
113
+ toolName: block.name,
114
+ toolInput: safeStringify(block.input),
115
+ rawPayload: JSON.stringify(block),
116
+ sourceFile,
117
+ });
118
+ } else if (block.type === 'tool_result') {
119
+ sequence += 1;
120
+ records.push({
121
+ id: buildEventId(sessionId, sequence),
122
+ sessionId,
123
+ eventType: 'tool_result',
124
+ sequence,
125
+ timestamp,
126
+ role: message.role,
127
+ toolOutput: safeStringify(block.content),
128
+ rawPayload: JSON.stringify(block),
129
+ sourceFile,
130
+ });
131
+ } else if (block.type === 'error') {
132
+ sequence += 1;
133
+ records.push({
134
+ id: buildEventId(sessionId, sequence),
135
+ sessionId,
136
+ eventType: 'error',
137
+ sequence,
138
+ timestamp,
139
+ role: message.role,
140
+ errorType: block.error?.type ?? 'unknown',
141
+ errorMessage: block.error?.message ?? 'Unknown error',
142
+ rawPayload: JSON.stringify(block),
143
+ sourceFile,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return records;
149
+ }
150
+
151
+ function buildEventId(sessionId: string, sequence: number): string {
152
+ return `${sessionId}:${sequence}`;
153
+ }
154
+
155
+ function safeStringify(input: unknown): string {
156
+ try {
157
+ return JSON.stringify(input);
158
+ } catch {
159
+ return '';
160
+ }
161
+ }
@@ -0,0 +1,223 @@
1
+ import { EventStore, AgentEventRecord } from '../storage/event-store.js';
2
+ import { SessionRecord } from '../storage/session-store.js';
3
+
4
+ export interface GeminiSessionPayload {
5
+ sessionId?: string;
6
+ projectHash?: string;
7
+ messages?: GeminiMessage[];
8
+ events?: GeminiMessage[];
9
+ chats?: GeminiMessage[];
10
+ }
11
+
12
+ interface GeminiMessage {
13
+ id?: string;
14
+ role?: string;
15
+ type?: string;
16
+ timestamp?: string;
17
+ text?: string;
18
+ content?: unknown;
19
+ toolName?: string;
20
+ toolArgs?: unknown;
21
+ toolResult?: unknown;
22
+ tool?: { name?: string; input?: unknown; output?: unknown };
23
+ message?: string;
24
+ }
25
+
26
+ export interface GeminiIngestResult {
27
+ inserted: number;
28
+ warnings: string[];
29
+ }
30
+
31
+ export class GeminiIngestor {
32
+ constructor(private events: EventStore) {}
33
+
34
+ ingest(session: SessionRecord, payload: GeminiSessionPayload): GeminiIngestResult {
35
+ const warnings: string[] = [];
36
+ const blocks = collectGeminiBlocks(payload);
37
+ if (!blocks.length) {
38
+ warnings.push('No Gemini messages found in payload');
39
+ return { inserted: 0, warnings };
40
+ }
41
+
42
+ const startSequence = this.events.maxSequence(session.id);
43
+ const records: AgentEventRecord[] = [];
44
+ let sequence = startSequence;
45
+ for (const block of blocks) {
46
+ sequence += 1;
47
+ const record = mapGeminiBlock(session.id, sequence, block);
48
+ if (record) {
49
+ records.push(record);
50
+ }
51
+ }
52
+
53
+ if (records.length) {
54
+ this.events.insert(records);
55
+ }
56
+ return { inserted: records.length, warnings };
57
+ }
58
+ }
59
+
60
+ interface NormalizedBlock {
61
+ kind: 'message' | 'tool_use' | 'tool_result';
62
+ role?: string;
63
+ text?: string;
64
+ toolName?: string;
65
+ toolInput?: unknown;
66
+ toolOutput?: unknown;
67
+ timestamp?: string;
68
+ raw?: unknown;
69
+ }
70
+
71
+ function collectGeminiBlocks(payload: GeminiSessionPayload): NormalizedBlock[] {
72
+ const sourceArrays = [payload.messages, payload.events, payload.chats].filter(
73
+ Array.isArray,
74
+ ) as GeminiMessage[][];
75
+ const blocks: NormalizedBlock[] = [];
76
+ for (const messages of sourceArrays) {
77
+ for (const message of messages) {
78
+ blocks.push(...normalizeGeminiMessage(message));
79
+ }
80
+ }
81
+ return blocks;
82
+ }
83
+
84
+ function normalizeGeminiMessage(message: GeminiMessage): NormalizedBlock[] {
85
+ const role = message.role || inferRoleFromType(message.type);
86
+ const timestamp = message.timestamp;
87
+ const blocks: NormalizedBlock[] = [];
88
+ const type = (message.type || '').toLowerCase();
89
+
90
+ if (type.includes('tool_call') || type === 'tool_use') {
91
+ blocks.push({
92
+ kind: 'tool_use',
93
+ role: role ?? 'assistant',
94
+ toolName: message.toolName ?? message.tool?.name,
95
+ toolInput: message.toolArgs ?? message.tool?.input ?? message.content,
96
+ timestamp,
97
+ raw: message,
98
+ });
99
+ return blocks;
100
+ }
101
+
102
+ if (type.includes('tool_result') || type === 'tool_execution_result') {
103
+ blocks.push({
104
+ kind: 'tool_result',
105
+ role: role ?? 'assistant',
106
+ toolName: message.toolName ?? message.tool?.name,
107
+ toolOutput: message.toolResult ?? message.tool?.output ?? message.content,
108
+ timestamp,
109
+ raw: message,
110
+ });
111
+ return blocks;
112
+ }
113
+
114
+ if (typeof message.text === 'string') {
115
+ blocks.push({
116
+ kind: 'message',
117
+ role: role ?? 'assistant',
118
+ text: message.text,
119
+ timestamp,
120
+ raw: message,
121
+ });
122
+ return blocks;
123
+ }
124
+
125
+ if (typeof message.content === 'string') {
126
+ blocks.push({
127
+ kind: 'message',
128
+ role: role ?? 'assistant',
129
+ text: message.content,
130
+ timestamp,
131
+ raw: message,
132
+ });
133
+ return blocks;
134
+ }
135
+
136
+ if (Array.isArray(message.content)) {
137
+ for (const part of message.content) {
138
+ if (typeof part === 'string') {
139
+ blocks.push({ kind: 'message', role: role ?? 'assistant', text: part, timestamp, raw: message });
140
+ } else if (part && typeof part === 'object') {
141
+ const blockType = (part.type || '').toLowerCase();
142
+ if (blockType === 'tool_use') {
143
+ blocks.push({
144
+ kind: 'tool_use',
145
+ role: role ?? 'assistant',
146
+ toolName: part.name,
147
+ toolInput: part.input,
148
+ timestamp,
149
+ raw: part,
150
+ });
151
+ } else if (blockType === 'tool_result') {
152
+ blocks.push({
153
+ kind: 'tool_result',
154
+ role: role ?? 'assistant',
155
+ toolName: part.name,
156
+ toolOutput: part.content ?? part.result,
157
+ timestamp,
158
+ raw: part,
159
+ });
160
+ } else if (blockType === 'text' && typeof part.text === 'string') {
161
+ blocks.push({ kind: 'message', role: role ?? 'assistant', text: part.text, timestamp, raw: part });
162
+ }
163
+ }
164
+ }
165
+ return blocks;
166
+ }
167
+
168
+ if (message.message) {
169
+ blocks.push({ kind: 'message', role: role ?? 'assistant', text: message.message, timestamp, raw: message });
170
+ }
171
+ return blocks;
172
+ }
173
+
174
+ function mapGeminiBlock(
175
+ sessionId: string,
176
+ sequence: number,
177
+ block: NormalizedBlock,
178
+ ): AgentEventRecord | null {
179
+ const timestamp = block.timestamp ? parseTimestamp(block.timestamp) : Date.now();
180
+ const base: AgentEventRecord = {
181
+ id: `${sessionId}:gemini:${sequence}`,
182
+ sessionId,
183
+ eventType: block.kind,
184
+ sequence,
185
+ timestamp,
186
+ role: block.role ?? 'assistant',
187
+ rawPayload: block.raw ? safeStringify(block.raw) : null,
188
+ sourceFile: 'gemini-ingest',
189
+ };
190
+
191
+ if (block.kind === 'message') {
192
+ return { ...base, text: block.text ?? '' };
193
+ }
194
+ if (block.kind === 'tool_use') {
195
+ return { ...base, toolName: block.toolName ?? 'unknown_tool', toolInput: safeStringify(block.toolInput) };
196
+ }
197
+ if (block.kind === 'tool_result') {
198
+ return { ...base, toolName: block.toolName ?? 'unknown_tool', toolOutput: safeStringify(block.toolOutput) };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function inferRoleFromType(type?: string): string | undefined {
204
+ if (!type) return undefined;
205
+ const value = type.toLowerCase();
206
+ if (value.includes('user')) return 'user';
207
+ if (value.includes('system')) return 'system';
208
+ return 'assistant';
209
+ }
210
+
211
+ function safeStringify(input: unknown): string | null {
212
+ if (input == null) return null;
213
+ try {
214
+ return JSON.stringify(input);
215
+ } catch {
216
+ return String(input);
217
+ }
218
+ }
219
+
220
+ function parseTimestamp(value: string): number {
221
+ const ms = Date.parse(value);
222
+ return Number.isNaN(ms) ? Date.now() : ms;
223
+ }
@@ -0,0 +1,38 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import { promisify } from 'node:util';
5
+ import { exec as execCb } from 'node:child_process';
6
+
7
+ const exec = promisify(execCb);
8
+
9
+ export type IntegrationAgent = 'codex' | 'claude' | 'gemini';
10
+
11
+ export async function installIntegration(agent: IntegrationAgent) {
12
+ switch (agent) {
13
+ case 'claude':
14
+ return installClaudeHook();
15
+ case 'gemini':
16
+ return installGeminiWatcher();
17
+ case 'codex':
18
+ return 'Codex integration does not require installation; history dir is created automatically.';
19
+ default:
20
+ throw new Error(`Unknown agent ${agent}`);
21
+ }
22
+ }
23
+
24
+ async function installClaudeHook(): Promise<string> {
25
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.config', 'claude');
26
+ const hookDir = path.join(configDir, 'hooks');
27
+ const scriptPath = path.join(hookDir, 'managerd-hook.sh');
28
+ await fs.mkdir(hookDir, { recursive: true });
29
+ const script = `#!/usr/bin/env bash\nset -euo pipefail\npayload="$(cat)"\nif [ -z "$payload" ]; then\n exit 0\nfi\nurl="${process.env.MANAGERD_HOOK_URL ?? 'http://127.0.0.1:7765/hooks/claude'}"\ncurl -fsS -X POST "$url" -H 'content-type: application/json' -d "$payload" >/dev/null 2>&1 || true\n`;
30
+ await fs.writeFile(scriptPath, script, { mode: 0o755 });
31
+ return `Hook script installed to ${scriptPath}. Point Claude Code hooks at this script to stream events to managerd.`;
32
+ }
33
+
34
+ async function installGeminiWatcher(): Promise<string> {
35
+ const sessionsDir = path.join(os.homedir(), '.gemini', 'tmp');
36
+ await fs.mkdir(sessionsDir, { recursive: true });
37
+ return `Gemini watcher enabled (managerd scans ${sessionsDir}). Ensure Gemini CLI writes session files there.`;
38
+ }
@@ -0,0 +1,173 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export type IntegrationAgent = 'codex' | 'claude' | 'gemini';
7
+
8
+ export interface IntegrationCheck {
9
+ name: string;
10
+ status: 'ok' | 'missing' | 'warn' | 'info';
11
+ detail?: string;
12
+ }
13
+
14
+ export interface IntegrationReport {
15
+ agent: IntegrationAgent;
16
+ status: 'ready' | 'partial' | 'not_detected';
17
+ checks: IntegrationCheck[];
18
+ manualSteps: string[];
19
+ }
20
+
21
+ const home = os.homedir();
22
+
23
+ export async function runIntegrationSync(agent: IntegrationAgent): Promise<IntegrationReport> {
24
+ switch (agent) {
25
+ case 'codex':
26
+ return syncCodex();
27
+ case 'claude':
28
+ return syncClaude();
29
+ case 'gemini':
30
+ return syncGemini();
31
+ default:
32
+ throw new Error(`Unknown integration agent: ${agent}`);
33
+ }
34
+ }
35
+
36
+ function syncCodex(): IntegrationReport {
37
+ const checks: IntegrationCheck[] = [];
38
+ const manualSteps: string[] = [];
39
+ const command = process.env.MANAGER_CODEX_COMMAND ?? 'codex';
40
+ const historyDir = process.env.MANAGER_CODEX_HISTORY ?? path.join(home, '.codex', 'sessions');
41
+ const configPath = path.join(home, '.codex', 'config.json');
42
+
43
+ const cmdPath = resolveCommand(command);
44
+ if (cmdPath) {
45
+ checks.push({ name: `Codex binary (${command})`, status: 'ok', detail: cmdPath });
46
+ } else {
47
+ checks.push({ name: `Codex binary (${command})`, status: 'missing', detail: 'not found on PATH' });
48
+ }
49
+
50
+ if (fs.existsSync(historyDir)) {
51
+ checks.push({ name: 'History directory', status: 'ok', detail: historyDir });
52
+ } else {
53
+ checks.push({ name: 'History directory', status: 'missing', detail: historyDir });
54
+ manualSteps.push(`Create the history directory: mkdir -p ${historyDir}`);
55
+ }
56
+
57
+ if (fs.existsSync(configPath)) {
58
+ try {
59
+ const raw = fs.readFileSync(configPath, 'utf8');
60
+ const config = JSON.parse(raw);
61
+ const configuredHistory = config.history_dir;
62
+ if (configuredHistory === historyDir) {
63
+ checks.push({ name: 'Codex history_dir', status: 'ok', detail: configuredHistory });
64
+ } else {
65
+ checks.push({
66
+ name: 'Codex history_dir',
67
+ status: 'warn',
68
+ detail: `Config uses ${configuredHistory || 'unset'}`,
69
+ });
70
+ manualSteps.push(`Update ${configPath} -> history_dir = "${historyDir}"`);
71
+ }
72
+ } catch (err: any) {
73
+ checks.push({ name: 'Codex config parse', status: 'warn', detail: err.message });
74
+ manualSteps.push(`Fix JSON in ${configPath} so manager can verify settings.`);
75
+ }
76
+ } else {
77
+ checks.push({ name: 'Codex config file', status: 'missing', detail: configPath });
78
+ manualSteps.push(`Create ${configPath} with { "history_dir": "${historyDir}" }`);
79
+ }
80
+
81
+ const status = determineStatus(checks, cmdPath ? 'ready' : 'not_detected');
82
+ return { agent: 'codex', status, checks, manualSteps };
83
+ }
84
+
85
+ function syncClaude(): IntegrationReport {
86
+ const checks: IntegrationCheck[] = [];
87
+ const manualSteps: string[] = [];
88
+ const configDir = path.join(home, '.config', 'claude');
89
+ const hookPath = path.join(configDir, 'hooks', 'on_session_start');
90
+
91
+ if (fs.existsSync(configDir)) {
92
+ checks.push({ name: 'Claude config directory', status: 'ok', detail: configDir });
93
+ } else {
94
+ checks.push({ name: 'Claude config directory', status: 'missing', detail: configDir });
95
+ manualSteps.push(`Create ${configDir} (Claude Code stores hooks here).`);
96
+ }
97
+
98
+ if (fs.existsSync(hookPath)) {
99
+ const content = fs.readFileSync(hookPath, 'utf8');
100
+ if (content.includes('manager')) {
101
+ checks.push({ name: 'Hook script', status: 'ok', detail: hookPath });
102
+ } else {
103
+ checks.push({ name: 'Hook script', status: 'warn', detail: 'does not reference manager CLI' });
104
+ manualSteps.push(`Update ${hookPath} to forward events to manager (see docs/manager-daemon-cli.md).`);
105
+ }
106
+ } else {
107
+ checks.push({ name: 'Hook script', status: 'missing', detail: hookPath });
108
+ manualSteps.push(`Create a hook script at ${hookPath} that runs manager's Claude hook helper.`);
109
+ }
110
+
111
+ return { agent: 'claude', status: determineStatus(checks), checks, manualSteps };
112
+ }
113
+
114
+ function syncGemini(): IntegrationReport {
115
+ const checks: IntegrationCheck[] = [];
116
+ const manualSteps: string[] = [];
117
+ const geminiDir = path.join(home, '.gemini');
118
+ const configPath = path.join(geminiDir, 'config.toml');
119
+ const desiredEndpoint = process.env.MANAGER_GEMINI_OTLP ?? 'http://127.0.0.1:4318';
120
+
121
+ if (fs.existsSync(geminiDir)) {
122
+ checks.push({ name: 'Gemini directory', status: 'ok', detail: geminiDir });
123
+ } else {
124
+ checks.push({ name: 'Gemini directory', status: 'missing', detail: geminiDir });
125
+ manualSteps.push('Install Gemini CLI and log in so ~/.gemini is created.');
126
+ }
127
+
128
+ if (fs.existsSync(configPath)) {
129
+ const text = fs.readFileSync(configPath, 'utf8');
130
+ const enabled = /telemetry\.enabled\s*=\s*true/.test(text);
131
+ const endpointMatch = text.includes(desiredEndpoint);
132
+ checks.push({
133
+ name: 'Telemetry enabled',
134
+ status: enabled ? 'ok' : 'warn',
135
+ detail: enabled ? 'enabled' : 'telemetry.enabled not true',
136
+ });
137
+ checks.push({
138
+ name: 'OTLP endpoint',
139
+ status: endpointMatch ? 'ok' : 'warn',
140
+ detail: endpointMatch ? desiredEndpoint : 'endpoint not set to manager',
141
+ });
142
+ if (!enabled || !endpointMatch) {
143
+ manualSteps.push(
144
+ `Edit ${configPath} telemetry section so exporter targets ${desiredEndpoint}.`,
145
+ );
146
+ }
147
+ } else {
148
+ checks.push({ name: 'Gemini config file', status: 'missing', detail: configPath });
149
+ manualSteps.push(`Create ${configPath} and set telemetry endpoint to ${desiredEndpoint}.`);
150
+ }
151
+
152
+ return { agent: 'gemini', status: determineStatus(checks), checks, manualSteps };
153
+ }
154
+
155
+ function determineStatus(checks: IntegrationCheck[], fallback: 'ready' | 'not_detected' = 'ready'): 'ready' | 'partial' | 'not_detected' {
156
+ if (checks.some((check) => check.status === 'missing')) {
157
+ return fallback === 'not_detected' && checks.every((c) => c.status === 'missing')
158
+ ? 'not_detected'
159
+ : 'partial';
160
+ }
161
+ if (checks.some((check) => check.status === 'warn')) {
162
+ return 'partial';
163
+ }
164
+ return fallback === 'not_detected' ? 'not_detected' : 'ready';
165
+ }
166
+
167
+ function resolveCommand(command: string): string | null {
168
+ const resolved = spawnSync('which', [command], { encoding: 'utf8' });
169
+ if (resolved.status === 0) {
170
+ return resolved.stdout.trim();
171
+ }
172
+ return null;
173
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { startDaemon } from './daemon/index.js';
3
+
4
+ startDaemon().catch((err) => {
5
+ console.error('Failed to start managerd', err);
6
+ process.exitCode = 1;
7
+ });