@ai-devkit/agent-manager 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js +249 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
- package/dist/__tests__/print/ClaudeCliProbe.test.js +53 -0
- package/dist/__tests__/print/ClaudeCliProbe.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintAgent.integration.test.js +69 -0
- package/dist/__tests__/print/ClaudePrintAgent.integration.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintAgentService.test.js +108 -0
- package/dist/__tests__/print/ClaudePrintAgentService.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintRunner.test.js +187 -0
- package/dist/__tests__/print/ClaudePrintRunner.test.js.map +1 -0
- package/dist/__tests__/print/PrintAgent.test.js +17 -0
- package/dist/__tests__/print/PrintAgent.test.js.map +1 -0
- package/dist/__tests__/print/PrintAgentStore.test.js +307 -0
- package/dist/__tests__/print/PrintAgentStore.test.js.map +1 -0
- package/dist/__tests__/terminal/TmuxManager.test.js +9 -0
- package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +9 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +106 -24
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/print/ClaudeCliProbe.d.ts +20 -0
- package/dist/print/ClaudeCliProbe.d.ts.map +1 -0
- package/dist/print/ClaudeCliProbe.js +57 -0
- package/dist/print/ClaudeCliProbe.js.map +1 -0
- package/dist/print/ClaudePrintAgentService.d.ts +44 -0
- package/dist/print/ClaudePrintAgentService.d.ts.map +1 -0
- package/dist/print/ClaudePrintAgentService.js +66 -0
- package/dist/print/ClaudePrintAgentService.js.map +1 -0
- package/dist/print/ClaudePrintRunner.d.ts +32 -0
- package/dist/print/ClaudePrintRunner.d.ts.map +1 -0
- package/dist/print/ClaudePrintRunner.js +128 -0
- package/dist/print/ClaudePrintRunner.js.map +1 -0
- package/dist/print/PrintAgent.d.ts +57 -0
- package/dist/print/PrintAgent.d.ts.map +1 -0
- package/dist/print/PrintAgent.js +42 -0
- package/dist/print/PrintAgent.js.map +1 -0
- package/dist/print/PrintAgentStore.d.ts +69 -0
- package/dist/print/PrintAgentStore.d.ts.map +1 -0
- package/dist/print/PrintAgentStore.js +484 -0
- package/dist/print/PrintAgentStore.js.map +1 -0
- package/dist/terminal/TmuxManager.d.ts +2 -2
- package/dist/terminal/TmuxManager.d.ts.map +1 -1
- package/dist/terminal/TmuxManager.js +5 -7
- package/dist/terminal/TmuxManager.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/CodexAdapter.test.ts +155 -0
- package/src/__tests__/fixtures/fake-claude.cjs +24 -0
- package/src/__tests__/print/ClaudeCliProbe.test.ts +32 -0
- package/src/__tests__/print/ClaudePrintAgent.integration.test.ts +56 -0
- package/src/__tests__/print/ClaudePrintAgentService.test.ts +46 -0
- package/src/__tests__/print/ClaudePrintRunner.test.ts +105 -0
- package/src/__tests__/print/PrintAgent.test.ts +21 -0
- package/src/__tests__/print/PrintAgentStore.test.ts +192 -0
- package/src/__tests__/terminal/TmuxManager.test.ts +10 -0
- package/src/adapters/CodexAdapter.ts +147 -27
- package/src/index.ts +39 -0
- package/src/print/ClaudeCliProbe.ts +58 -0
- package/src/print/ClaudePrintAgentService.ts +94 -0
- package/src/print/ClaudePrintRunner.ts +139 -0
- package/src/print/PrintAgent.ts +86 -0
- package/src/print/PrintAgentStore.ts +503 -0
- package/src/terminal/TmuxManager.ts +5 -7
|
@@ -106,6 +106,16 @@ describe('TmuxManager', () => {
|
|
|
106
106
|
expect(await tmux.findAgentPid('foo', matchesClaude)).toBeNull();
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('returns the matching pane PID when the agent replaces the shell', async () => {
|
|
110
|
+
setExecFileHandler((cmd, args) => {
|
|
111
|
+
if (cmd === 'tmux' && args[0] === 'list-panes') return '100\n';
|
|
112
|
+
if (cmd === 'pgrep') return new Error('no children');
|
|
113
|
+
if (cmd === 'ps' && args[1] === '100') return '/usr/local/bin/claude';
|
|
114
|
+
return '';
|
|
115
|
+
});
|
|
116
|
+
expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(100);
|
|
117
|
+
});
|
|
118
|
+
|
|
109
119
|
it('returns the matching descendant when found', async () => {
|
|
110
120
|
// pane 100 → child 200 (claude) — no grandchildren
|
|
111
121
|
setExecFileHandler((cmd, args) => {
|
|
@@ -37,9 +37,26 @@ interface CodexEventEntry {
|
|
|
37
37
|
id?: string;
|
|
38
38
|
cwd?: string;
|
|
39
39
|
timestamp?: string;
|
|
40
|
+
role?: string;
|
|
41
|
+
content?: CodexContent[];
|
|
42
|
+
item?: CodexItem;
|
|
43
|
+
turn_id?: string;
|
|
44
|
+
internal_chat_message_metadata_passthrough?: {
|
|
45
|
+
turn_id?: string;
|
|
46
|
+
};
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
interface CodexContent {
|
|
51
|
+
type?: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CodexItem {
|
|
56
|
+
type?: string;
|
|
57
|
+
content?: string | CodexContent[];
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
interface CodexSession {
|
|
44
61
|
sessionId: string;
|
|
45
62
|
projectPath: string;
|
|
@@ -502,7 +519,7 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
502
519
|
}
|
|
503
520
|
|
|
504
521
|
const lastEntry = this.findLastEventEntry(entries);
|
|
505
|
-
const lastPayloadType = lastEntry
|
|
522
|
+
const lastPayloadType = lastEntry ? this.normalizedPayloadType(lastEntry) : undefined;
|
|
506
523
|
|
|
507
524
|
const lastActive =
|
|
508
525
|
this.parseTimestamp(lastEntry?.timestamp) ||
|
|
@@ -596,15 +613,44 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
596
613
|
|
|
597
614
|
private extractSummary(entries: CodexEventEntry[]): string {
|
|
598
615
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
599
|
-
const message = entries[i]
|
|
600
|
-
if (
|
|
601
|
-
return this.truncate(message.trim(), 120);
|
|
602
|
-
}
|
|
616
|
+
const message = this.extractEntryText(entries[i]);
|
|
617
|
+
if (message) return this.truncate(message, 120);
|
|
603
618
|
}
|
|
604
619
|
|
|
605
620
|
return 'Codex session active';
|
|
606
621
|
}
|
|
607
622
|
|
|
623
|
+
private normalizedPayloadType(entry: CodexEventEntry): string | undefined {
|
|
624
|
+
const payloadType = entry.payload?.type;
|
|
625
|
+
|
|
626
|
+
if (entry.type === 'response_item' && payloadType === 'message') {
|
|
627
|
+
if (entry.payload?.role === 'assistant') return 'agent_message';
|
|
628
|
+
if (entry.payload?.role === 'user') return 'user_message';
|
|
629
|
+
return payloadType;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (entry.type === 'event_msg' && payloadType === 'item_completed') {
|
|
633
|
+
const itemType = entry.payload?.item?.type;
|
|
634
|
+
if (itemType === 'AgentMessage') return 'agent_message';
|
|
635
|
+
if (itemType === 'UserMessage') return 'user_message';
|
|
636
|
+
return itemType ?? payloadType;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return payloadType;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private extractEntryText(entry: CodexEventEntry | undefined): string {
|
|
643
|
+
if (!entry) return '';
|
|
644
|
+
|
|
645
|
+
const legacyMessage = entry.payload?.message;
|
|
646
|
+
if (typeof legacyMessage === 'string' && legacyMessage.trim().length > 0) {
|
|
647
|
+
return legacyMessage.trim();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const conversationMessage = this.toConversationMessage(entry, false);
|
|
651
|
+
return conversationMessage?.content.trim() ?? '';
|
|
652
|
+
}
|
|
653
|
+
|
|
608
654
|
private truncate(value: string, maxLength: number): string {
|
|
609
655
|
if (value.length <= maxLength) return value;
|
|
610
656
|
return `${value.slice(0, maxLength - 3)}...`;
|
|
@@ -619,7 +665,8 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
619
665
|
/**
|
|
620
666
|
* Read the full conversation from a Codex session JSONL file.
|
|
621
667
|
*
|
|
622
|
-
* Codex entries use
|
|
668
|
+
* Codex entries use either legacy payload.message fields or current
|
|
669
|
+
* response_item/event_msg content arrays.
|
|
623
670
|
*/
|
|
624
671
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
625
672
|
const verbose = options?.verbose ?? false;
|
|
@@ -628,45 +675,118 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
628
675
|
if (content === undefined) return [];
|
|
629
676
|
|
|
630
677
|
const lines = content.trim().split('\n');
|
|
678
|
+
const entries: CodexEventEntry[] = [];
|
|
631
679
|
const messages: ConversationMessage[] = [];
|
|
632
680
|
|
|
633
681
|
for (const line of lines) {
|
|
634
|
-
let entry: CodexEventEntry;
|
|
635
682
|
try {
|
|
636
|
-
|
|
683
|
+
entries.push(JSON.parse(line));
|
|
637
684
|
} catch {
|
|
638
685
|
continue;
|
|
639
686
|
}
|
|
687
|
+
}
|
|
640
688
|
|
|
641
|
-
|
|
689
|
+
const responseItemMirrorKeys = new Set<string>();
|
|
690
|
+
for (const entry of entries) {
|
|
691
|
+
if (entry.type !== 'response_item') continue;
|
|
642
692
|
|
|
643
|
-
const
|
|
644
|
-
|
|
693
|
+
const message = this.toConversationMessage(entry, verbose);
|
|
694
|
+
const mirrorKey = message ? this.mirroredMessageKey(entry, message) : null;
|
|
695
|
+
if (mirrorKey) responseItemMirrorKeys.add(mirrorKey);
|
|
696
|
+
}
|
|
645
697
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
698
|
+
for (const entry of entries) {
|
|
699
|
+
const message = this.toConversationMessage(entry, verbose);
|
|
700
|
+
if (!message) continue;
|
|
701
|
+
|
|
702
|
+
const mirrorKey = this.mirroredMessageKey(entry, message);
|
|
703
|
+
if (
|
|
704
|
+
entry.type === 'event_msg' &&
|
|
705
|
+
mirrorKey &&
|
|
706
|
+
responseItemMirrorKeys.has(mirrorKey)
|
|
707
|
+
) {
|
|
654
708
|
continue;
|
|
655
709
|
}
|
|
656
710
|
|
|
657
|
-
|
|
658
|
-
if (!text) continue;
|
|
659
|
-
|
|
660
|
-
messages.push({
|
|
661
|
-
role,
|
|
662
|
-
content: text,
|
|
663
|
-
timestamp: entry.timestamp,
|
|
664
|
-
});
|
|
711
|
+
messages.push(message);
|
|
665
712
|
}
|
|
666
713
|
|
|
667
714
|
return messages;
|
|
668
715
|
}
|
|
669
716
|
|
|
717
|
+
private toConversationMessage(entry: CodexEventEntry, verbose: boolean): ConversationMessage | null {
|
|
718
|
+
if (entry.type === 'session_meta') return null;
|
|
719
|
+
|
|
720
|
+
const payloadType = entry.payload?.type;
|
|
721
|
+
if (entry.type === 'response_item' && payloadType === 'message') {
|
|
722
|
+
const role = this.mapCodexRole(entry.payload?.role, verbose);
|
|
723
|
+
const text = this.extractContentText(entry.payload?.content);
|
|
724
|
+
if (!role || !text) return null;
|
|
725
|
+
|
|
726
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (entry.type === 'event_msg' && payloadType === 'item_completed') {
|
|
730
|
+
const item = entry.payload?.item;
|
|
731
|
+
const role = this.mapCodexItemRole(item?.type, verbose);
|
|
732
|
+
const text = this.extractContentText(item?.content);
|
|
733
|
+
if (!role || !text) return null;
|
|
734
|
+
|
|
735
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!payloadType) return null;
|
|
739
|
+
|
|
740
|
+
let role: ConversationMessage['role'];
|
|
741
|
+
if (payloadType === 'user_message') {
|
|
742
|
+
role = 'user';
|
|
743
|
+
} else if (payloadType === 'agent_message' || payloadType === 'task_complete') {
|
|
744
|
+
role = 'assistant';
|
|
745
|
+
} else if (verbose) {
|
|
746
|
+
role = 'system';
|
|
747
|
+
} else {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const text = entry.payload?.message?.trim();
|
|
752
|
+
if (!text) return null;
|
|
753
|
+
|
|
754
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private mapCodexRole(role: string | undefined, verbose: boolean): ConversationMessage['role'] | null {
|
|
758
|
+
if (role === 'user') return 'user';
|
|
759
|
+
if (role === 'assistant') return 'assistant';
|
|
760
|
+
return verbose ? 'system' : null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private mapCodexItemRole(itemType: string | undefined, verbose: boolean): ConversationMessage['role'] | null {
|
|
764
|
+
if (itemType === 'AgentMessage') return 'assistant';
|
|
765
|
+
if (itemType === 'UserMessage') return 'user';
|
|
766
|
+
return verbose ? 'system' : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private mirroredMessageKey(entry: CodexEventEntry, message: ConversationMessage): string | null {
|
|
770
|
+
const turnId =
|
|
771
|
+
entry.payload?.turn_id ||
|
|
772
|
+
entry.payload?.internal_chat_message_metadata_passthrough?.turn_id;
|
|
773
|
+
|
|
774
|
+
if (!turnId) return null;
|
|
775
|
+
return `${turnId}\0${message.role}\0${message.content}`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private extractContentText(content: string | CodexContent[] | undefined): string {
|
|
779
|
+
if (typeof content === 'string') return content.trim();
|
|
780
|
+
if (!Array.isArray(content)) return '';
|
|
781
|
+
|
|
782
|
+
return content
|
|
783
|
+
.map((part) => part.text)
|
|
784
|
+
.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
|
785
|
+
.map((text) => text.trim())
|
|
786
|
+
.join('\n')
|
|
787
|
+
.trim();
|
|
788
|
+
}
|
|
789
|
+
|
|
670
790
|
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
671
791
|
if (!isDirectory(this.codexSessionsDir)) return [];
|
|
672
792
|
|
package/src/index.ts
CHANGED
|
@@ -34,3 +34,42 @@ export type { AgentConfig, StartableAgentType } from './utils/agents.js';
|
|
|
34
34
|
|
|
35
35
|
export type { AgentRequest } from './utils/agent-requests.js';
|
|
36
36
|
export { getAgentRequestPath, readLatestAgentRequest, writeAgentRequest } from './utils/agent-requests.js';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
PrintAgentError,
|
|
40
|
+
PrintAgentBusyError,
|
|
41
|
+
PrintAgentNotFoundError,
|
|
42
|
+
PrintAgentStoreError,
|
|
43
|
+
PrintAgentNameConflictError,
|
|
44
|
+
ClaudePrintError,
|
|
45
|
+
} from './print/PrintAgent.js';
|
|
46
|
+
export type {
|
|
47
|
+
PrintAgent,
|
|
48
|
+
PrintAgentState,
|
|
49
|
+
PrintSessionHealth,
|
|
50
|
+
PrintRunStatus,
|
|
51
|
+
PrintActiveRun,
|
|
52
|
+
PrintLastResult,
|
|
53
|
+
ProcessIdentity,
|
|
54
|
+
} from './print/PrintAgent.js';
|
|
55
|
+
export { PrintAgentStore } from './print/PrintAgentStore.js';
|
|
56
|
+
export { LocalProcessInspector } from './print/PrintAgentStore.js';
|
|
57
|
+
export type {
|
|
58
|
+
CreatePrintAgentInput,
|
|
59
|
+
PrintAgentStoreOptions,
|
|
60
|
+
ProcessInspector,
|
|
61
|
+
PrintRunCompletion,
|
|
62
|
+
} from './print/PrintAgentStore.js';
|
|
63
|
+
export { ClaudeCliProbe } from './print/ClaudeCliProbe.js';
|
|
64
|
+
export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js';
|
|
65
|
+
export { ClaudePrintRunner } from './print/ClaudePrintRunner.js';
|
|
66
|
+
export type {
|
|
67
|
+
ClaudePrintRunnerOptions,
|
|
68
|
+
ClaudePrintRunRequest,
|
|
69
|
+
ClaudePrintRunResult,
|
|
70
|
+
} from './print/ClaudePrintRunner.js';
|
|
71
|
+
export { ClaudePrintAgentService } from './print/ClaudePrintAgentService.js';
|
|
72
|
+
export type {
|
|
73
|
+
ClaudePrintAgentServiceOptions,
|
|
74
|
+
ClaudePrintSendResult,
|
|
75
|
+
} from './print/ClaudePrintAgentService.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { ClaudePrintError } from './PrintAgent.js';
|
|
4
|
+
|
|
5
|
+
type ExecResult = { stdout: string; stderr: string };
|
|
6
|
+
type Exec = (file: string, args: string[]) => Promise<ExecResult>;
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const REQUIRED = ['--print', '--session-id', '--resume', '--output-format', 'stream-json'];
|
|
10
|
+
|
|
11
|
+
export interface ClaudeCliProbeOptions {
|
|
12
|
+
executable?: string;
|
|
13
|
+
exec?: Exec;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ClaudeCliProbe {
|
|
17
|
+
private readonly executable: string;
|
|
18
|
+
private readonly exec: Exec;
|
|
19
|
+
|
|
20
|
+
constructor(options: ClaudeCliProbeOptions = {}) {
|
|
21
|
+
this.executable = options.executable ?? 'claude';
|
|
22
|
+
this.exec = options.exec ?? (async (file, args) => {
|
|
23
|
+
const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
24
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async validate(): Promise<{ executable: string; version: string }> {
|
|
29
|
+
try {
|
|
30
|
+
const versionResult = await this.exec(this.executable, ['--version']);
|
|
31
|
+
const helpResult = await this.exec(this.executable, ['--help']);
|
|
32
|
+
const missing = REQUIRED.filter((capability) => !helpResult.stdout.includes(capability));
|
|
33
|
+
if (missing.length > 0) {
|
|
34
|
+
throw new ClaudePrintError(
|
|
35
|
+
`Claude CLI does not support required print-mode capabilities: ${missing.join(', ')}.`,
|
|
36
|
+
'CLAUDE_CLI_UNSUPPORTED',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
executable: this.executable,
|
|
41
|
+
version: sanitize(versionResult.stdout, 256) || 'unknown',
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof ClaudePrintError) throw error;
|
|
45
|
+
throw new ClaudePrintError(
|
|
46
|
+
`Claude CLI validation failed: ${sanitize((error as Error).message, 512)}`,
|
|
47
|
+
'CLAUDE_CLI_UNAVAILABLE',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sanitize(value: string, max: number): string {
|
|
54
|
+
return Array.from(value, (character) => {
|
|
55
|
+
const code = character.charCodeAt(0);
|
|
56
|
+
return code <= 31 || code === 127 ? ' ' : character;
|
|
57
|
+
}).join('').trim().slice(0, max);
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PrintAgent, ProcessIdentity } from './PrintAgent.js';
|
|
2
|
+
import { ClaudePrintError, PrintAgentNotFoundError } from './PrintAgent.js';
|
|
3
|
+
import { ClaudeCliProbe } from './ClaudeCliProbe.js';
|
|
4
|
+
import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js';
|
|
5
|
+
import { PrintAgentStore, type CreatePrintAgentInput, type PrintRunCompletion } from './PrintAgentStore.js';
|
|
6
|
+
|
|
7
|
+
interface StoreLike {
|
|
8
|
+
create(input: CreatePrintAgentInput): Promise<PrintAgent>;
|
|
9
|
+
list(): Promise<PrintAgent[]>;
|
|
10
|
+
resolve(reference: string): Promise<PrintAgent | PrintAgent[] | null>;
|
|
11
|
+
acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>;
|
|
12
|
+
recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise<void>;
|
|
13
|
+
completeRun(id: string, token: string, result: PrintRunCompletion): Promise<PrintAgent>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProbeLike { validate(): Promise<{ executable: string; version: string }> }
|
|
17
|
+
interface RunnerLike { run(request: Parameters<ClaudePrintRunner['run']>[0]): Promise<ClaudePrintRunResult> }
|
|
18
|
+
|
|
19
|
+
export interface ClaudePrintAgentServiceOptions {
|
|
20
|
+
store?: StoreLike;
|
|
21
|
+
probe?: ProbeLike;
|
|
22
|
+
runner?: RunnerLike;
|
|
23
|
+
executable?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClaudePrintSendResult extends ClaudePrintRunResult {
|
|
27
|
+
agentId: string;
|
|
28
|
+
agentName: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ClaudePrintAgentService {
|
|
32
|
+
readonly store: StoreLike;
|
|
33
|
+
private readonly probe: ProbeLike;
|
|
34
|
+
private readonly runner: RunnerLike;
|
|
35
|
+
private readonly executable?: string;
|
|
36
|
+
|
|
37
|
+
constructor(options: ClaudePrintAgentServiceOptions = {}) {
|
|
38
|
+
this.store = options.store ?? new PrintAgentStore();
|
|
39
|
+
this.probe = options.probe ?? new ClaudeCliProbe();
|
|
40
|
+
this.runner = options.runner ?? new ClaudePrintRunner();
|
|
41
|
+
this.executable = options.executable;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async create(input: CreatePrintAgentInput): Promise<PrintAgent> {
|
|
45
|
+
await this.probe.validate();
|
|
46
|
+
return this.store.create(input);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async send(reference: string, prompt: string): Promise<ClaudePrintSendResult> {
|
|
50
|
+
const resolved = await this.store.resolve(reference);
|
|
51
|
+
if (!resolved) throw new PrintAgentNotFoundError(reference);
|
|
52
|
+
if (Array.isArray(resolved)) {
|
|
53
|
+
throw new ClaudePrintError(`Multiple print agents match "${reference}".`, 'PRINT_AGENT_AMBIGUOUS');
|
|
54
|
+
}
|
|
55
|
+
const acquired = await this.store.acquireRun(resolved.id);
|
|
56
|
+
try {
|
|
57
|
+
const result = await this.runner.run({
|
|
58
|
+
agent: acquired.agent,
|
|
59
|
+
prompt,
|
|
60
|
+
executable: this.executable,
|
|
61
|
+
firstRun: acquired.agent.sessionHealth === 'uninitialized',
|
|
62
|
+
onSpawn: (identity) => this.store.recordProviderProcess(resolved.id, acquired.token, identity),
|
|
63
|
+
});
|
|
64
|
+
await this.store.completeRun(resolved.id, acquired.token, {
|
|
65
|
+
status: 'succeeded',
|
|
66
|
+
exitCode: result.exitCode,
|
|
67
|
+
summary: sanitize(result.result, 4096),
|
|
68
|
+
sessionHealth: 'healthy',
|
|
69
|
+
});
|
|
70
|
+
return { ...result, agentId: resolved.id, agentName: resolved.name };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
73
|
+
const sessionHealth = error instanceof ClaudePrintError && error.code === 'CLAUDE_SESSION_MISMATCH'
|
|
74
|
+
? 'mismatch' as const
|
|
75
|
+
: 'unknown' as const;
|
|
76
|
+
await this.store.completeRun(resolved.id, acquired.token, {
|
|
77
|
+
status: 'failed',
|
|
78
|
+
exitCode: null,
|
|
79
|
+
summary: sanitize(failure.message, 4096),
|
|
80
|
+
sessionHealth,
|
|
81
|
+
});
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sanitize(value: string, max: number): string {
|
|
88
|
+
return Array.from(value, (character) => {
|
|
89
|
+
const code = character.charCodeAt(0);
|
|
90
|
+
return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127)
|
|
91
|
+
? ' '
|
|
92
|
+
: character;
|
|
93
|
+
}).join('').trim().slice(0, max);
|
|
94
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process';
|
|
2
|
+
import type { PrintAgent, ProcessIdentity } from './PrintAgent.js';
|
|
3
|
+
import { ClaudePrintError } from './PrintAgent.js';
|
|
4
|
+
import { LocalProcessInspector, type ProcessInspector } from './PrintAgentStore.js';
|
|
5
|
+
|
|
6
|
+
type Spawn = (
|
|
7
|
+
command: string,
|
|
8
|
+
args: readonly string[],
|
|
9
|
+
options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] },
|
|
10
|
+
) => ChildProcessWithoutNullStreams;
|
|
11
|
+
|
|
12
|
+
export interface ClaudePrintRunRequest {
|
|
13
|
+
agent: PrintAgent;
|
|
14
|
+
prompt: string;
|
|
15
|
+
executable?: string;
|
|
16
|
+
firstRun: boolean;
|
|
17
|
+
onSpawn(identity: ProcessIdentity): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClaudePrintRunResult {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
result: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClaudePrintRunnerOptions {
|
|
27
|
+
spawn?: Spawn;
|
|
28
|
+
processInspector?: ProcessInspector;
|
|
29
|
+
maxLineBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ClaudePrintRunner {
|
|
33
|
+
private readonly spawn: Spawn;
|
|
34
|
+
private readonly processInspector: ProcessInspector;
|
|
35
|
+
private readonly maxLineBytes: number;
|
|
36
|
+
|
|
37
|
+
constructor(options: ClaudePrintRunnerOptions = {}) {
|
|
38
|
+
this.spawn = options.spawn ?? (nodeSpawn as Spawn);
|
|
39
|
+
this.processInspector = options.processInspector ?? new LocalProcessInspector();
|
|
40
|
+
this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async run(request: ClaudePrintRunRequest): Promise<ClaudePrintRunResult> {
|
|
44
|
+
const sessionArgs = request.firstRun
|
|
45
|
+
? ['--session-id', request.agent.providerSessionId]
|
|
46
|
+
: ['--resume', request.agent.providerSessionId];
|
|
47
|
+
const args = ['-p', ...sessionArgs, '--output-format', 'stream-json', '--verbose'];
|
|
48
|
+
const child = this.spawn(request.executable ?? 'claude', args, {
|
|
49
|
+
cwd: request.agent.cwd,
|
|
50
|
+
shell: false,
|
|
51
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
+
});
|
|
53
|
+
if (!child.pid) {
|
|
54
|
+
child.kill();
|
|
55
|
+
throw new ClaudePrintError('Claude process did not provide a PID.', 'CLAUDE_PROCESS_IDENTITY');
|
|
56
|
+
}
|
|
57
|
+
const identity = this.processInspector.getIdentity(child.pid);
|
|
58
|
+
if (!identity) {
|
|
59
|
+
child.kill();
|
|
60
|
+
throw new ClaudePrintError('Cannot verify Claude process identity.', 'CLAUDE_PROCESS_IDENTITY');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let buffer = Buffer.alloc(0);
|
|
64
|
+
let terminal: ClaudePrintRunResult | null = null;
|
|
65
|
+
let protocolError: ClaudePrintError | null = null;
|
|
66
|
+
|
|
67
|
+
child.stdout.on('data', (chunk: Buffer | string) => {
|
|
68
|
+
if (protocolError) return;
|
|
69
|
+
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
70
|
+
if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) {
|
|
71
|
+
protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let newline: number;
|
|
75
|
+
while ((newline = buffer.indexOf(0x0a)) >= 0) {
|
|
76
|
+
const line = buffer.subarray(0, newline);
|
|
77
|
+
buffer = buffer.subarray(newline + 1);
|
|
78
|
+
if (line.length === 0) continue;
|
|
79
|
+
if (line.length > this.maxLineBytes) {
|
|
80
|
+
protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const value = JSON.parse(line.toString('utf8')) as unknown;
|
|
85
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
86
|
+
throw new ClaudePrintError('Claude emitted a non-object stream message.', 'CLAUDE_STREAM_INVALID');
|
|
87
|
+
}
|
|
88
|
+
const event = value as Record<string, unknown>;
|
|
89
|
+
if (typeof event.session_id === 'string' && event.session_id !== request.agent.providerSessionId) {
|
|
90
|
+
throw new ClaudePrintError('Claude returned a different session identity.', 'CLAUDE_SESSION_MISMATCH');
|
|
91
|
+
}
|
|
92
|
+
if (event.type === 'result') {
|
|
93
|
+
if (terminal) throw new ClaudePrintError('Claude emitted more than one terminal result.', 'CLAUDE_STREAM_INVALID');
|
|
94
|
+
if (typeof event.session_id !== 'string' || typeof event.result !== 'string') {
|
|
95
|
+
throw new ClaudePrintError('Claude emitted an invalid terminal result.', 'CLAUDE_STREAM_INVALID');
|
|
96
|
+
}
|
|
97
|
+
terminal = { sessionId: event.session_id, result: event.result, exitCode: 0 };
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
protocolError = error instanceof ClaudePrintError
|
|
101
|
+
? error
|
|
102
|
+
: new ClaudePrintError('Claude emitted malformed stream JSON.', 'CLAUDE_STREAM_INVALID');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
// Drain provider diagnostics without reflecting potentially sensitive prompt/tool data.
|
|
108
|
+
child.stderr.resume();
|
|
109
|
+
|
|
110
|
+
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
111
|
+
child.once('error', reject);
|
|
112
|
+
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await request.onSpawn(identity);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
child.kill();
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
child.stdin.end(request.prompt);
|
|
123
|
+
const { code, signal } = await closed;
|
|
124
|
+
|
|
125
|
+
if (protocolError) throw protocolError;
|
|
126
|
+
if (buffer.length > 0) {
|
|
127
|
+
throw new ClaudePrintError('Claude stream ended with incomplete JSON.', 'CLAUDE_STREAM_INVALID');
|
|
128
|
+
}
|
|
129
|
+
if (code !== 0) {
|
|
130
|
+
throw new ClaudePrintError(
|
|
131
|
+
`Claude print run failed${signal ? ` (${signal})` : '.'}`,
|
|
132
|
+
'CLAUDE_PROCESS_FAILED',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!terminal) throw new ClaudePrintError('Claude stream ended without a terminal result.', 'CLAUDE_RESULT_MISSING');
|
|
136
|
+
const finalResult = terminal as ClaudePrintRunResult;
|
|
137
|
+
return { sessionId: finalResult.sessionId, result: finalResult.result, exitCode: code };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type PrintAgentState = 'ready' | 'running' | 'degraded';
|
|
2
|
+
export type PrintSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch';
|
|
3
|
+
export type PrintRunStatus = 'succeeded' | 'failed' | 'interrupted';
|
|
4
|
+
|
|
5
|
+
export interface ProcessIdentity {
|
|
6
|
+
pid: number;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PrintActiveRun {
|
|
11
|
+
token: string;
|
|
12
|
+
owner: ProcessIdentity;
|
|
13
|
+
provider: ProcessIdentity | null;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PrintLastResult {
|
|
18
|
+
status: PrintRunStatus;
|
|
19
|
+
completedAt: string;
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PrintAgent {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
provider: 'claude';
|
|
28
|
+
mode: 'print';
|
|
29
|
+
cwd: string;
|
|
30
|
+
providerSessionId: string;
|
|
31
|
+
state: PrintAgentState;
|
|
32
|
+
sessionHealth: PrintSessionHealth;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
lastActiveAt: string | null;
|
|
36
|
+
lastResult: PrintLastResult | null;
|
|
37
|
+
activeRun: PrintActiveRun | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PrintAgentError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
message: string,
|
|
43
|
+
public readonly code: string,
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'PrintAgentError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PrintAgentBusyError extends PrintAgentError {
|
|
51
|
+
constructor(
|
|
52
|
+
public readonly agentId: string,
|
|
53
|
+
agentName: string,
|
|
54
|
+
) {
|
|
55
|
+
super(`Print agent "${agentName}" is busy.`, 'PRINT_AGENT_BUSY');
|
|
56
|
+
this.name = 'PrintAgentBusyError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class PrintAgentNotFoundError extends PrintAgentError {
|
|
61
|
+
constructor(public readonly reference: string) {
|
|
62
|
+
super(`Print agent "${reference}" was not found.`, 'PRINT_AGENT_NOT_FOUND');
|
|
63
|
+
this.name = 'PrintAgentNotFoundError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class PrintAgentStoreError extends PrintAgentError {
|
|
68
|
+
constructor(message: string) {
|
|
69
|
+
super(message, 'PRINT_AGENT_STORE');
|
|
70
|
+
this.name = 'PrintAgentStoreError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class PrintAgentNameConflictError extends PrintAgentError {
|
|
75
|
+
constructor(public readonly agentName: string) {
|
|
76
|
+
super(`Print agent name "${agentName}" is already in use.`, 'PRINT_AGENT_NAME_CONFLICT');
|
|
77
|
+
this.name = 'PrintAgentNameConflictError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ClaudePrintError extends PrintAgentError {
|
|
82
|
+
constructor(message: string, code = 'CLAUDE_PRINT_FAILED') {
|
|
83
|
+
super(message, code);
|
|
84
|
+
this.name = 'ClaudePrintError';
|
|
85
|
+
}
|
|
86
|
+
}
|