ai-stream-tee 1.3.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,60 @@
1
+ import cp from 'node:child_process';
2
+ import { inspect } from 'node:util';
3
+ import { log } from '../log.js';
4
+ export class BaseAgent {
5
+ exec;
6
+ args;
7
+ constructor(exec, args) {
8
+ this.exec = exec;
9
+ this.args = args;
10
+ }
11
+ execute(client, { execPath }) {
12
+ const child_process = cp.spawn(execPath ?? this.exec, this.args, {
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ });
15
+ child_process.stdout
16
+ .on('data', (data) => {
17
+ client.put(data);
18
+ const output = data.toString('utf-8');
19
+ output.split('\n').forEach(line => {
20
+ if (line.trim()) {
21
+ this.handleLine(line);
22
+ }
23
+ });
24
+ })
25
+ .on('end', () => {
26
+ client.stop(false, 'stdout end');
27
+ })
28
+ .on('error', (err) => {
29
+ client.stop(true, `stdout error: ${err.message}`);
30
+ });
31
+ child_process.stderr.pipe(process.stderr, { end: false });
32
+ child_process
33
+ .on('error', async (err) => {
34
+ log('ERROR', `failed to spawn claude code: ${inspect(err)}`);
35
+ client.stop(true, `spawn error: ${err.message}`);
36
+ process.exit(1);
37
+ })
38
+ .on('close', (code, signal) => {
39
+ if (code != null) {
40
+ this.handleClose(code);
41
+ if (code === 0) {
42
+ log('INFO', `${this.exec} close with code ${code}`);
43
+ }
44
+ else {
45
+ log('ERROR', `${this.exec} close with code ${code}`);
46
+ }
47
+ process.exitCode = code;
48
+ }
49
+ else {
50
+ log('ERROR', `${this.exec} close with signal: ${signal}`);
51
+ process.exitCode = -1;
52
+ }
53
+ client.wait()
54
+ .finally(() => {
55
+ process.exit();
56
+ });
57
+ });
58
+ return child_process;
59
+ }
60
+ }
@@ -0,0 +1,37 @@
1
+ import { BaseAgent } from './base.js';
2
+ export class Claude extends BaseAgent {
3
+ constructor(args) {
4
+ super('claude', ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose', ...args]);
5
+ }
6
+ result;
7
+ handleLine(line) {
8
+ try {
9
+ const chunk = JSON.parse(line);
10
+ if (chunk.type === 'result') {
11
+ this.result = chunk;
12
+ }
13
+ }
14
+ catch {
15
+ }
16
+ }
17
+ handleClose(code) {
18
+ const result = this.result;
19
+ if (result) {
20
+ if (result.subtype === 'success') {
21
+ if (result.is_error) {
22
+ // Force exit with error
23
+ process.stderr.write(result.result + '\n');
24
+ }
25
+ else {
26
+ process.stdout.write(result.result + '\n');
27
+ }
28
+ }
29
+ else {
30
+ process.stderr.write(result.subtype + '\n');
31
+ }
32
+ }
33
+ else {
34
+ process.stderr.write(`claude code exit (${code}) with no result message.`);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,41 @@
1
+ import { BaseAgent } from './base.js';
2
+ export class Codex extends BaseAgent {
3
+ final_message;
4
+ final_turn_failed;
5
+ constructor(args) {
6
+ super('codex', ['exec', '--json', ...args]);
7
+ }
8
+ handleLine(line) {
9
+ try {
10
+ const chunk = JSON.parse(line);
11
+ if (chunk.type === 'item.completed') {
12
+ if (chunk.item.type === 'agent_message') {
13
+ this.final_message = chunk.item;
14
+ }
15
+ }
16
+ if (chunk.type === 'turn.failed') {
17
+ this.final_turn_failed = chunk;
18
+ }
19
+ }
20
+ catch {
21
+ }
22
+ }
23
+ handleClose(code) {
24
+ if (code === 0) {
25
+ if (this.final_message) {
26
+ process.stdout.write(this.final_message.text + '\n');
27
+ }
28
+ else {
29
+ process.stdout.write('No final message.\n');
30
+ }
31
+ }
32
+ else {
33
+ if (this.final_turn_failed) {
34
+ process.stderr.write(this.final_turn_failed.error.message + '\n');
35
+ }
36
+ else {
37
+ process.stderr.write(`codex exit (${code}) with no error result.\n`);
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,36 @@
1
+ import { BaseAgent } from './base.js';
2
+ export class PantheonTdd extends BaseAgent {
3
+ final_item;
4
+ constructor(args) {
5
+ super('dev-agent', ['--headless', '--stream-json', ...args]);
6
+ }
7
+ handleLine(line) {
8
+ try {
9
+ const chunk = JSON.parse(line);
10
+ if (chunk.type === 'thread.completed') {
11
+ this.final_item = chunk;
12
+ }
13
+ }
14
+ catch {
15
+ }
16
+ }
17
+ handleClose(code) {
18
+ if (code === 0) {
19
+ if (this.final_item) {
20
+ if (this.final_item.status === 'error') {
21
+ process.stderr.write(this.final_item.summary);
22
+ process.exitCode = 1;
23
+ }
24
+ else {
25
+ process.stdout.write(JSON.stringify(this.final_item.final_report, undefined, 2) + '\n');
26
+ }
27
+ }
28
+ else {
29
+ process.stdout.write('No final item.\n');
30
+ }
31
+ }
32
+ else {
33
+ process.stderr.write(`tdd exit (${code}).\n`);
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,51 @@
1
+ import { BaseAgent } from './base.js';
2
+ export class PantheonAgent extends BaseAgent {
3
+ final_item;
4
+ constructor(subAgent, args) {
5
+ super(subAgent, ['--headless', '--stream-json', ...args]);
6
+ }
7
+ handleLine(line) {
8
+ try {
9
+ const chunk = JSON.parse(line);
10
+ if (chunk.type === 'thread.completed') {
11
+ this.final_item = chunk;
12
+ }
13
+ }
14
+ catch {
15
+ }
16
+ }
17
+ handleClose(code) {
18
+ if (code === 0) {
19
+ if (this.final_item) {
20
+ if (this.final_item.status === 'error') {
21
+ process.stderr.write(this.final_item.summary);
22
+ process.exitCode = 1;
23
+ }
24
+ else {
25
+ process.stdout.write(JSON.stringify(this.final_item.final_report, undefined, 2) + '\n');
26
+ }
27
+ }
28
+ else {
29
+ process.stdout.write('No final item.\n');
30
+ }
31
+ }
32
+ else {
33
+ process.stderr.write(`tdd exit (${code}).\n`);
34
+ }
35
+ }
36
+ static determineSubAgent(name) {
37
+ switch (name) {
38
+ case 'dev':
39
+ case 'tdd':
40
+ case 'pantheon-tdd':
41
+ case 'dev-agent':
42
+ return 'dev-agent';
43
+ case 'review':
44
+ case 'pantheon-review':
45
+ case 'review-agent':
46
+ return 'review';
47
+ default:
48
+ throw new Error(`Invalid pantheon agent ${name}`);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,70 @@
1
+ import { BaseAgent } from './base.js';
2
+ export class Qwen extends BaseAgent {
3
+ constructor(args) {
4
+ // Qwen Code CLI arguments
5
+ super('qwen', ['--output-format', 'stream-json', ...args]);
6
+ }
7
+ lastContent = '';
8
+ usage;
9
+ hasError = false;
10
+ handleLine(line) {
11
+ try {
12
+ const chunk = JSON.parse(line);
13
+ switch (chunk.type) {
14
+ case 'content':
15
+ if (chunk.content) {
16
+ this.lastContent += chunk.content;
17
+ // Forward content to stdout for streaming
18
+ process.stdout.write(JSON.stringify({
19
+ type: 'content',
20
+ content: chunk.content,
21
+ }) + '\n');
22
+ }
23
+ break;
24
+ case 'finish':
25
+ this.usage = chunk.usage;
26
+ // Forward finish event
27
+ process.stdout.write(JSON.stringify({
28
+ type: 'finish',
29
+ reason: chunk.reason,
30
+ usage: chunk.usage,
31
+ }) + '\n');
32
+ break;
33
+ case 'error':
34
+ this.hasError = true;
35
+ // Forward error event
36
+ process.stderr.write(JSON.stringify({
37
+ type: 'error',
38
+ error: chunk.error,
39
+ message: chunk.message,
40
+ }) + '\n');
41
+ break;
42
+ }
43
+ }
44
+ catch (e) {
45
+ // If parsing fails, treat as plain text output
46
+ if (line.trim()) {
47
+ this.lastContent += line + '\n';
48
+ process.stdout.write(JSON.stringify({
49
+ type: 'content',
50
+ content: line,
51
+ }) + '\n');
52
+ }
53
+ }
54
+ }
55
+ handleClose(code) {
56
+ const exitEvent = {
57
+ type: 'finish',
58
+ reason: this.hasError ? 'error' : 'stop',
59
+ exit_code: code,
60
+ usage: this.usage,
61
+ };
62
+ process.stdout.write(JSON.stringify(exitEvent) + '\n');
63
+ if (this.hasError || code !== 0) {
64
+ process.stderr.write(`qwen code exited with code ${code}\n`);
65
+ if (this.lastContent) {
66
+ process.stderr.write(`Last output: ${this.lastContent.slice(0, 500)}\n`);
67
+ }
68
+ }
69
+ }
70
+ }
package/dist/client.js ADDED
@@ -0,0 +1,173 @@
1
+ import { inspect } from 'node:util';
2
+ import { Agent, Dispatcher, fetch } from 'undici';
3
+ import { log } from './log.js';
4
+ export class StreamClient {
5
+ streamServerUrl;
6
+ streamServerToken;
7
+ streamId;
8
+ streamMessageId;
9
+ contentType;
10
+ buf = [];
11
+ chunks = [];
12
+ finalPending = [];
13
+ agent;
14
+ cursor = 0;
15
+ headers = {};
16
+ init;
17
+ initialized = false;
18
+ heartbeatTimeout;
19
+ failed = false;
20
+ constructor(streamServerUrl, streamServerToken, streamId, streamMessageId, contentType) {
21
+ this.streamServerUrl = streamServerUrl;
22
+ this.streamServerToken = streamServerToken;
23
+ this.streamId = streamId;
24
+ this.streamMessageId = streamMessageId;
25
+ this.contentType = contentType;
26
+ this.agent = new Agent();
27
+ if (streamServerToken) {
28
+ this.headers['Authorization'] = `Bearer ${streamServerToken}`;
29
+ }
30
+ this.scheduleHeartbeat();
31
+ this.init = retryIfFailed('init stream', async () => {
32
+ await fetch(`${this.streamServerUrl}/v2/streams`, {
33
+ method: 'POST',
34
+ headers: {
35
+ ...this.headers,
36
+ 'Content-Type': 'application/json',
37
+ },
38
+ dispatcher: this.agent,
39
+ body: JSON.stringify({
40
+ stream_id: streamId,
41
+ message_id: streamMessageId ?? streamId,
42
+ content_type: contentType,
43
+ }),
44
+ }).then(handleResponse('init stream'));
45
+ })
46
+ .then(() => {
47
+ this.initialized = true;
48
+ this.buf.forEach(chunk => this.put(chunk));
49
+ this.buf = [];
50
+ })
51
+ .catch(() => {
52
+ this.failed = true;
53
+ this.cancelHeartbeat();
54
+ });
55
+ }
56
+ cancelHeartbeat() {
57
+ clearTimeout(this.heartbeatTimeout);
58
+ }
59
+ scheduleHeartbeat() {
60
+ clearTimeout(this.heartbeatTimeout);
61
+ this.heartbeatTimeout = setTimeout(() => {
62
+ fetch(`${this.streamServerUrl}/v2/streams/${encodeURIComponent(this.streamId)}/actions/heartbeat`, {
63
+ method: 'POST',
64
+ headers: {
65
+ ...this.headers,
66
+ },
67
+ dispatcher: this.agent,
68
+ keepalive: true,
69
+ }).catch(() => { })
70
+ .finally(() => {
71
+ this.scheduleHeartbeat();
72
+ });
73
+ }, 5000);
74
+ }
75
+ async wait() {
76
+ await this.init;
77
+ if (this.failed) {
78
+ return;
79
+ }
80
+ const chunks = this.chunks;
81
+ this.chunks = [];
82
+ await Promise.allSettled(chunks);
83
+ if (this.chunks.length > 0) {
84
+ await this.wait();
85
+ }
86
+ else {
87
+ await Promise.allSettled(this.finalPending.map(fp => fp()));
88
+ }
89
+ }
90
+ put(data) {
91
+ if (this.failed) {
92
+ return;
93
+ }
94
+ if (!this.initialized) {
95
+ this.buf.push(data);
96
+ return;
97
+ }
98
+ const range = `bytes ${this.cursor}-${this.cursor + data.length - 1}`;
99
+ this.cursor += data.length;
100
+ this.chunks.push(retryIfFailed(`send range ${range}`, async () => {
101
+ await fetch(`${this.streamServerUrl}/v2/streams/${encodeURIComponent(this.streamId)}/content`, {
102
+ method: 'PUT',
103
+ headers: {
104
+ ...this.headers,
105
+ 'X-Content-Range': range,
106
+ },
107
+ body: data,
108
+ dispatcher: this.agent,
109
+ keepalive: true,
110
+ }).then(handleResponse('put data'));
111
+ }).catch(() => {
112
+ this.stop(true, 'failed to send data');
113
+ this.failed = true;
114
+ this.cancelHeartbeat();
115
+ }).finally(() => {
116
+ if (!this.failed) {
117
+ this.scheduleHeartbeat();
118
+ }
119
+ }));
120
+ }
121
+ stop(abort, reason) {
122
+ this.init.then(() => {
123
+ if (this.failed) {
124
+ return;
125
+ }
126
+ log('INFO', `${abort ? 'abort' : 'stop'} stream ${this.cursor}: ${reason}\n`);
127
+ this.cancelHeartbeat();
128
+ this.finalPending.push(() => retryIfFailed(`end stream`, async () => {
129
+ await fetch(`${this.streamServerUrl}/v2/streams/${encodeURIComponent(this.streamId)}/actions/stop`, {
130
+ method: 'POST',
131
+ headers: {
132
+ ...this.headers,
133
+ 'Content-Type': 'application/json',
134
+ },
135
+ body: JSON.stringify({
136
+ stop_state: abort ? 'abort' : 'done',
137
+ stop_reason: reason,
138
+ final_size: this.cursor,
139
+ }),
140
+ dispatcher: this.agent,
141
+ keepalive: true,
142
+ }).then(handleResponse('stop stream'));
143
+ }));
144
+ });
145
+ }
146
+ }
147
+ function handleResponse(action) {
148
+ return async (res) => {
149
+ if (!res.ok) {
150
+ throw new Error(`failed to ${action}: ${res.status} ${await res.text().catch(() => res.statusText)}`);
151
+ }
152
+ };
153
+ }
154
+ async function retryIfFailed(action, cb, times = 3) {
155
+ let attempt = 0;
156
+ for (let i = 0; i < times; i++) {
157
+ try {
158
+ await cb();
159
+ return;
160
+ }
161
+ catch (e) {
162
+ attempt++;
163
+ if (attempt < times) {
164
+ log('WARN', `failed to ${action}, retrying after 1 second... (${attempt}/${times}) ${inspect(e)}`);
165
+ await new Promise(resolve => setTimeout(resolve, 1000));
166
+ }
167
+ else {
168
+ log('ERROR', `failed to ${action}, giving up. (${attempt}/${times}) ${inspect(e)}`);
169
+ throw e;
170
+ }
171
+ }
172
+ }
173
+ }
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Claude } from './agents/claude.js';
7
+ import { Codex } from './agents/codex.js';
8
+ import { PantheonTdd } from './agents/pantheon-tdd.js';
9
+ import { PantheonAgent } from './agents/pantheon.js';
10
+ import { Qwen } from './agents/qwen.js';
11
+ import { StreamClient } from './client.js';
12
+ import { log, setSilent } from './log.js';
13
+ const packageJsonDir = path.resolve(fileURLToPath(import.meta.url), '../../package.json');
14
+ const VERSION = JSON.parse(fs.readFileSync(packageJsonDir, 'utf-8')).version;
15
+ const command = program
16
+ .version(VERSION)
17
+ .argument('<agent>', '`claude` or `codex`')
18
+ .requiredOption('--stream-server-url <string>', 'ai stream proxy server url e.g. http://localhost:8888.')
19
+ .requiredOption('--stream-id <string>', 'stream id for this agent execution.')
20
+ .option('--stream-message-id <string>', 'message id for this agent execution. Default to stream-id.')
21
+ .option('--stream-protocol <string>', 'v2', 'v2')
22
+ .option('--stream-server-token <string>', 'auth token')
23
+ .option('--exec-path <string>', 'path to agent executable. Default to standard agent name in PATH.')
24
+ .option('--suppress-logs', 'suppress all logs except for errors.', false)
25
+ .allowUnknownOption()
26
+ .allowExcessArguments()
27
+ .action(async function (action, options) {
28
+ const { operands, unknown } = this.parseOptions(process.argv.slice(2));
29
+ const { streamServerUrl, streamServerToken, streamId, streamMessageId, execPath, suppressLogs, } = options;
30
+ if (suppressLogs) {
31
+ setSilent(true);
32
+ }
33
+ log('INFO', VERSION);
34
+ const [agent, ...restOperands] = operands;
35
+ let a;
36
+ let contentType;
37
+ switch (agent) {
38
+ case 'claude':
39
+ a = new Claude([...unknown, ...restOperands]);
40
+ contentType = 'claude-code-stream-json+include-partial-messages';
41
+ break;
42
+ case 'codex':
43
+ a = new Codex([...unknown, ...restOperands]);
44
+ contentType = 'codex-stream-json';
45
+ break;
46
+ case 'pantheon-tdd':
47
+ a = new PantheonTdd([...unknown, ...restOperands]);
48
+ contentType = 'pantheon-tdd-stream-json';
49
+ log('WARN', 'pantheon-tdd is deprecated, use code-tee pantheon dev');
50
+ break;
51
+ case 'pantheon': {
52
+ const [subAgent, ...otherOperands] = restOperands;
53
+ a = new PantheonAgent(PantheonAgent.determineSubAgent(subAgent), [...unknown, ...otherOperands]);
54
+ contentType = 'pantheon-agent-stream-json';
55
+ break;
56
+ }
57
+ case 'qwen':
58
+ a = new Qwen([...unknown, ...restOperands]);
59
+ contentType = 'qwen-stream-json';
60
+ break;
61
+ default:
62
+ log('ERROR', `unknown agent ${agent}`);
63
+ process.exit(1);
64
+ }
65
+ a.execute(new StreamClient(streamServerUrl, streamServerToken, streamId, streamMessageId, contentType), { execPath });
66
+ });
67
+ command.parse();
package/dist/log.js ADDED
@@ -0,0 +1,12 @@
1
+ let silentMode = false;
2
+ export function setSilent(silent) {
3
+ silentMode = true;
4
+ }
5
+ export function log(level, str) {
6
+ if (silentMode) {
7
+ if (level === 'INFO' || level === 'WARN') {
8
+ return;
9
+ }
10
+ }
11
+ process.stderr.write(`[code-tee ${Date.now()} ${level.padStart(5, ' ')}]: ${str}\n`);
12
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ai-stream-tee",
3
+ "version": "1.3.0",
4
+ "description": "Multi-agent CLI wrapper with Qwen support",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "dependencies": {
11
+ "commander": "^14.0.0",
12
+ "undici": "^7.16.0"
13
+ },
14
+ "bin": {
15
+ "ai-stream-tee": "./dist/index.js"
16
+ },
17
+ "files": [
18
+ "readme.md",
19
+ "dist"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.14.0"
23
+ },
24
+ "devDependencies": {
25
+ "@anthropic-ai/claude-code": "^1.0.128",
26
+ "@openai/codex": "^0.60.1",
27
+ "@openai/codex-sdk": "^0.60.1",
28
+ "@types/node": "^22.18.0",
29
+ "pantheon-tdd-sdk": "^1.0.0",
30
+ "typescript": "^5.9.2"
31
+ }
32
+ }
package/readme.md ADDED
@@ -0,0 +1,16 @@
1
+ ## Usage
2
+
3
+ ```shell
4
+ # Claude Code
5
+ code-tee \
6
+ --stream-server-url "http://127.0.0.1:3000" \
7
+ --stream-id "my-magic-stream-id" \
8
+ --stream-message-id "my-magic-stream-message-id" \
9
+ claude "Hi"
10
+
11
+ # Codex
12
+ code-tee \
13
+ --target-url "http://127.0.0.1:3000" \
14
+ --stream-id "my-magic-stream-id" \
15
+ codex "Hi"
16
+ ```