traintrack 2.0.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/LICENSE +201 -0
- package/README.md +158 -0
- package/dist/channel/channel.js +64 -0
- package/dist/channel/resolve.js +43 -0
- package/dist/cli.js +164 -0
- package/dist/index.js +6 -0
- package/dist/mcp/server.js +198 -0
- package/dist/mcp/tools.js +207 -0
- package/dist/mcp-server.js +8 -0
- package/dist/onboarding/briefing.js +24 -0
- package/dist/runner/argv.js +47 -0
- package/dist/runner/event-parser.js +165 -0
- package/dist/runner/turn-runner.js +81 -0
- package/dist/setup/blocks.js +251 -0
- package/dist/setup/configure.js +179 -0
- package/dist/setup/detect.js +53 -0
- package/dist/setup/harness.js +61 -0
- package/dist/setup/prompt.js +218 -0
- package/dist/setup/setup.js +106 -0
- package/dist/setup/types.js +9 -0
- package/dist/spawn/spawn.js +90 -0
- package/dist/ui/banner.js +76 -0
- package/dist/worker/worker.js +190 -0
- package/hooks/hooks-codex.json +16 -0
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/hooks.json +16 -0
- package/hooks/run-hook.cmd +46 -0
- package/hooks/session-start +44 -0
- package/hooks/session-start-codex +28 -0
- package/package.json +52 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// ─── Traintrack stdio MCP server ─────────────────────────────────────────────
|
|
2
|
+
// A tiny stdio MCP (Model Context Protocol) server that the lead's TUI talks to.
|
|
3
|
+
// It exposes four tools — spawn_worker, await_results, send_message,
|
|
4
|
+
// check_messages — each operating directly on a local SQLite Channel. There is
|
|
5
|
+
// no RuntimeClient and no Orca runtime here: this process OWNS its Channel.
|
|
6
|
+
//
|
|
7
|
+
// The lead's own handle (used as `from` on outgoing messages and `to` for the
|
|
8
|
+
// inbox it drains) is TRAINTRACK_HANDLE, defaulting to 'lead'. The Channel db path is
|
|
9
|
+
// TRAINTRACK_CHANNEL, defaulting to <cwd>/.traintrack/channel.db.
|
|
10
|
+
//
|
|
11
|
+
// MCP transport is newline-delimited JSON-RPC 2.0 over stdio. The protocol
|
|
12
|
+
// surface we need is small (initialize, tools/list, tools/call, ping, the
|
|
13
|
+
// initialized notification), so this is hand-rolled rather than pulling in the
|
|
14
|
+
// SDK. The protocol logic is split from the stdin/stdout wiring so it is
|
|
15
|
+
// unit-testable without a process.
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { Channel } from '../channel/channel.js';
|
|
19
|
+
import { resolveChannelPath } from '../channel/resolve.js';
|
|
20
|
+
import { spawnWorker } from '../spawn/spawn.js';
|
|
21
|
+
import { TOOLS, spawnWorkerTool, awaitResultsTool, sendMessageTool, checkMessagesTool, listTeamTool, delegateTaskTool, joinTeamTool, } from './tools.js';
|
|
22
|
+
const SERVER_NAME = 'traintrack';
|
|
23
|
+
const SERVER_VERSION = '0.1.0';
|
|
24
|
+
// The MCP protocol version we implement. We echo the client's requested version
|
|
25
|
+
// when present (forward-compat with newer clients) and fall back to this.
|
|
26
|
+
const DEFAULT_PROTOCOL_VERSION = '2024-11-05';
|
|
27
|
+
function ok(id, result) {
|
|
28
|
+
return { jsonrpc: '2.0', id, result };
|
|
29
|
+
}
|
|
30
|
+
function errorResult(text) {
|
|
31
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
async function callTool(name, args, deps) {
|
|
34
|
+
try {
|
|
35
|
+
switch (name) {
|
|
36
|
+
case 'spawn_worker':
|
|
37
|
+
return await spawnWorkerTool(args, deps);
|
|
38
|
+
case 'await_results':
|
|
39
|
+
return await awaitResultsTool(args, deps);
|
|
40
|
+
case 'send_message':
|
|
41
|
+
return await sendMessageTool(args, deps);
|
|
42
|
+
case 'check_messages':
|
|
43
|
+
return await checkMessagesTool(args, deps);
|
|
44
|
+
case 'list_team':
|
|
45
|
+
return listTeamTool(deps);
|
|
46
|
+
case 'delegate_task': {
|
|
47
|
+
const to = typeof args.to === 'string' ? args.to.trim() : '';
|
|
48
|
+
const task = typeof args.task === 'string' ? args.task.trim() : '';
|
|
49
|
+
if (!to || !task) {
|
|
50
|
+
return errorResult('"to" (a teammate handle) and "task" are both required.');
|
|
51
|
+
}
|
|
52
|
+
return delegateTaskTool(deps, to, task);
|
|
53
|
+
}
|
|
54
|
+
case 'join_team': {
|
|
55
|
+
const handle = typeof args.handle === 'string' ? args.handle.trim() : '';
|
|
56
|
+
const role = typeof args.role === 'string' ? args.role.trim() : '';
|
|
57
|
+
const agent = typeof args.agent === 'string' ? args.agent.trim() : undefined;
|
|
58
|
+
if (!handle || !role) {
|
|
59
|
+
return errorResult('"handle" and "role" are both required to join a team.');
|
|
60
|
+
}
|
|
61
|
+
return joinTeamTool(deps, handle, role, agent);
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// Why: a tool (e.g. spawnWorker's git worktree add) can throw. Surface it as
|
|
69
|
+
// an isError result so the model sees the failure text rather than the
|
|
70
|
+
// transport dropping the call.
|
|
71
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
72
|
+
return errorResult(`Tool "${name}" failed: ${detail}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Handle one parsed JSON-RPC message. Returns the response to write, or null
|
|
76
|
+
* for notifications (no id) — which get no reply. Pure except for the injected
|
|
77
|
+
* deps (Channel + spawnWorker), so it is fully unit-testable. */
|
|
78
|
+
export async function handleMessage(req, deps) {
|
|
79
|
+
// A JSON-RPC notification omits `id` — process side effects, send nothing
|
|
80
|
+
// back. This covers notifications/initialized and any other notification.
|
|
81
|
+
if (req.id === undefined) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const id = req.id;
|
|
85
|
+
const method = req.method;
|
|
86
|
+
if (method === 'initialize') {
|
|
87
|
+
const requested = req.params?.protocolVersion;
|
|
88
|
+
const protocolVersion = typeof requested === 'string' ? requested : DEFAULT_PROTOCOL_VERSION;
|
|
89
|
+
return ok(id, {
|
|
90
|
+
protocolVersion,
|
|
91
|
+
capabilities: { tools: {} },
|
|
92
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (method === 'ping') {
|
|
96
|
+
return ok(id, {});
|
|
97
|
+
}
|
|
98
|
+
if (method === 'tools/list') {
|
|
99
|
+
return ok(id, { tools: TOOLS });
|
|
100
|
+
}
|
|
101
|
+
if (method === 'tools/call') {
|
|
102
|
+
const name = typeof req.params?.name === 'string' ? req.params.name : '';
|
|
103
|
+
const args = req.params?.arguments && typeof req.params.arguments === 'object'
|
|
104
|
+
? req.params.arguments
|
|
105
|
+
: {};
|
|
106
|
+
return ok(id, withUnreadNudge(await callTool(name, args, deps), name, deps));
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
jsonrpc: '2.0',
|
|
110
|
+
id,
|
|
111
|
+
error: { code: -32601, message: `Method not found: ${method ?? ''}` },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Build the tool deps from the environment AND auto-join this live session to
|
|
115
|
+
* its project team. The channel is resolved to the GIT REPO ROOT (so every
|
|
116
|
+
* session in a project shares one team — see resolveChannelPath). The session's
|
|
117
|
+
* handle is TRAINTRACK_HANDLE or a freshly-minted `<agent>-<rand>`, its agent is
|
|
118
|
+
* TRAINTRACK_AGENT (default 'claude'), and it registers itself as a live member
|
|
119
|
+
* on startup so teammates see it immediately. */
|
|
120
|
+
export function buildDepsFromEnv() {
|
|
121
|
+
const dbPath = resolveChannelPath();
|
|
122
|
+
const channel = new Channel(dbPath);
|
|
123
|
+
const agent = process.env['TRAINTRACK_AGENT'] ?? 'claude';
|
|
124
|
+
const role = process.env['TRAINTRACK_ROLE'] ?? 'lead';
|
|
125
|
+
const handle = process.env['TRAINTRACK_HANDLE'] ?? `${agent}-${randomBytes(3).toString('hex')}`;
|
|
126
|
+
// Auto-presence: this hand-driven session joins its project team on startup so
|
|
127
|
+
// teammates discover it with zero fiddling (no explicit join / channel path).
|
|
128
|
+
channel.addMember({ handle, agent, role, kind: 'live', status: 'active', worktree: process.cwd() });
|
|
129
|
+
return { self: handle, channel, spawnWorker };
|
|
130
|
+
}
|
|
131
|
+
/** Append a one-line "you have mail" nudge to a tool result when the session has
|
|
132
|
+
* unread messages — so a live agent learns of teammate messages the moment it
|
|
133
|
+
* uses any tool, without a fragile per-turn OS hook. check_messages/await_results
|
|
134
|
+
* already surface messages, so they are skipped. */
|
|
135
|
+
export function withUnreadNudge(result, name, deps) {
|
|
136
|
+
if (name === 'check_messages' || name === 'await_results')
|
|
137
|
+
return result;
|
|
138
|
+
let unread = 0;
|
|
139
|
+
try {
|
|
140
|
+
unread = deps.channel.getUnread(deps.self).length;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
if (unread === 0)
|
|
146
|
+
return result;
|
|
147
|
+
const note = `\n\n📨 ${unread} unread message${unread === 1 ? '' : 's'} from teammates — call check_messages to read ${unread === 1 ? 'it' : 'them'}.`;
|
|
148
|
+
const content = result.content.length ? result.content : [{ type: 'text', text: '' }];
|
|
149
|
+
const last = content[content.length - 1];
|
|
150
|
+
return {
|
|
151
|
+
...result,
|
|
152
|
+
content: [...content.slice(0, -1), { type: 'text', text: (last.text || '') + note }],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Run the stdio MCP loop until the input stream closes. Reads newline-delimited
|
|
156
|
+
* JSON-RPC requests and writes newline-delimited responses. Streams + deps are
|
|
157
|
+
* injectable so the full loop (readline framing → handler) is testable
|
|
158
|
+
* end-to-end; the defaults wire the real process stdio + env. */
|
|
159
|
+
export function runTraintrackMcpServer(io = {}) {
|
|
160
|
+
const deps = io.deps ?? buildDepsFromEnv();
|
|
161
|
+
const usingRealStdin = !io.input;
|
|
162
|
+
const input = io.input ?? process.stdin;
|
|
163
|
+
const output = io.output ?? process.stdout;
|
|
164
|
+
const rl = createInterface({ input });
|
|
165
|
+
rl.on('line', (line) => {
|
|
166
|
+
const trimmed = line.trim();
|
|
167
|
+
if (!trimmed) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let req;
|
|
171
|
+
try {
|
|
172
|
+
req = JSON.parse(trimmed);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
void handleMessage(req, deps).then((res) => {
|
|
179
|
+
if (res) {
|
|
180
|
+
output.write(`${JSON.stringify(res)}\n`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
rl.on('close', () => {
|
|
185
|
+
// Mark this session offline so teammates' rosters reflect it leaving.
|
|
186
|
+
try {
|
|
187
|
+
deps.channel.setStatus(deps.self, 'offline');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// best-effort; never block shutdown
|
|
191
|
+
}
|
|
192
|
+
// Only tear the process down when we own the real stdin; an injected stream
|
|
193
|
+
// closing (a test) must not kill the host process.
|
|
194
|
+
if (usingRealStdin) {
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ─── Traintrack MCP tools ─────────────────────────────────────────────────────
|
|
2
|
+
// The four coordination tools the lead's TUI drives over a local Channel:
|
|
3
|
+
// spawn_worker(agent, role, task) — hand out work to a fresh worker
|
|
4
|
+
// await_results(timeoutMs?) — block for the workers' replies
|
|
5
|
+
// send_message(to, body) — fire-and-forget message to one handle
|
|
6
|
+
// check_messages() — drain this lead's unread inbox
|
|
7
|
+
//
|
|
8
|
+
// Each tool takes its dependencies (the lead's own handle, the Channel, the
|
|
9
|
+
// spawnWorker impl, and a sleep) injected, so they are unit-testable against a
|
|
10
|
+
// real temp-file Channel with a faked spawnWorker and an instant sleep — no
|
|
11
|
+
// process, no readline, no protocol shell.
|
|
12
|
+
import { gitRoot } from '../channel/resolve.js';
|
|
13
|
+
/** The MCP tool definitions advertised by tools/list. */
|
|
14
|
+
export const TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'spawn_worker',
|
|
17
|
+
description: "Spawn a new worker agent of the given type and role in its own git worktree, and assign it a task. Returns the new worker's handle so you can reference it later. As lead, call spawn_worker to hand out work, then call await_results to block until the workers reply with their results.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
agent: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'The agent type to spawn — "claude" or "codex".',
|
|
24
|
+
},
|
|
25
|
+
role: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'The role name for the new worker, e.g. "api", "ui", "tests".',
|
|
28
|
+
},
|
|
29
|
+
task: { type: 'string', description: 'The initial task to assign to the spawned worker.' },
|
|
30
|
+
},
|
|
31
|
+
required: ['agent', 'role', 'task'],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'await_results',
|
|
37
|
+
description: "Block until a worker replies with results (or the timeout expires). Use this AFTER calling spawn_worker to collect the workers' replies. Returns the formatted replies addressed to you, or a timeout message if none arrive in time.",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
timeoutMs: {
|
|
42
|
+
type: 'number',
|
|
43
|
+
description: 'How long to wait for results in milliseconds. Defaults to 120000 (2 minutes).',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'send_message',
|
|
51
|
+
description: 'Send a message to another agent in this workspace. It is posted to that agent in the BACKGROUND via the channel — they read it on their own cadence with check_messages — so it never interrupts them.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
to: { type: 'string', description: 'The recipient worker handle.' },
|
|
56
|
+
body: { type: 'string', description: 'The message text to send.' },
|
|
57
|
+
},
|
|
58
|
+
required: ['to', 'body'],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'check_messages',
|
|
64
|
+
description: 'Read and consume the unread messages addressed to you — anything your workers have sent, including their results. This marks them read so you do not see them again. Use it to catch up without blocking.',
|
|
65
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'list_team',
|
|
69
|
+
description: 'List all teammates currently registered in this workspace — their handles, agent types, roles, and status. Use this to see who you can delegate to before calling delegate_task, or after spawn_worker to confirm a new worker is registered.',
|
|
70
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'delegate_task',
|
|
74
|
+
description: 'Assign a task to an existing teammate by handle. This posts a task message to their inbox via the channel — they pick it up on their own cadence. Use list_team to see valid handles, spawn_worker to add a new teammate, then await_results to collect their reply.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
to: { type: 'string', description: 'The handle of the teammate to delegate to (must already exist in the team).' },
|
|
79
|
+
task: { type: 'string', description: 'The task description to assign to the teammate.' },
|
|
80
|
+
},
|
|
81
|
+
required: ['to', 'task'],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'join_team',
|
|
87
|
+
description: 'Join an EXISTING team as a live member. Use this when you are NOT the lead — e.g. a human added your session to a running team and you should start receiving its messages. Registers you in the shared roster under the given handle and role so teammates can reach you, and binds this session to that handle. After joining, call check_messages to pick up anything addressed to you.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
handle: { type: 'string', description: 'The unique handle to register yourself under, e.g. "reviewer".' },
|
|
92
|
+
role: { type: 'string', description: 'Your role on the team, e.g. "reviewer", "qa".' },
|
|
93
|
+
agent: { type: 'string', description: 'Optional: your agent type, "claude" or "codex" (defaults to "claude").' },
|
|
94
|
+
},
|
|
95
|
+
required: ['handle', 'role'],
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
const VALID_AGENTS = ['claude', 'codex'];
|
|
101
|
+
function textResult(text) {
|
|
102
|
+
return { content: [{ type: 'text', text }] };
|
|
103
|
+
}
|
|
104
|
+
function errorResult(text) {
|
|
105
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
106
|
+
}
|
|
107
|
+
function realSleep(ms) {
|
|
108
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
109
|
+
}
|
|
110
|
+
function formatMessage(m) {
|
|
111
|
+
return `- [${m.id}] from ${m.from}: ${m.body}`;
|
|
112
|
+
}
|
|
113
|
+
/** Drain a non-empty batch of unread messages: mark them read and format. */
|
|
114
|
+
function drain(messages, channel, header) {
|
|
115
|
+
channel.markRead(messages.map((m) => m.id));
|
|
116
|
+
return textResult(`${header}:\n${messages.map(formatMessage).join('\n')}`);
|
|
117
|
+
}
|
|
118
|
+
export async function spawnWorkerTool(args, deps) {
|
|
119
|
+
const agent = typeof args.agent === 'string' ? args.agent.trim() : '';
|
|
120
|
+
const role = typeof args.role === 'string' ? args.role.trim() : '';
|
|
121
|
+
const task = typeof args.task === 'string' ? args.task.trim() : '';
|
|
122
|
+
if (!agent || !role || !task) {
|
|
123
|
+
return errorResult('"agent", "role", and "task" are all required.');
|
|
124
|
+
}
|
|
125
|
+
if (!VALID_AGENTS.includes(agent)) {
|
|
126
|
+
return errorResult(`Unknown agent "${agent}". Valid agents: ${VALID_AGENTS.join(', ')}.`);
|
|
127
|
+
}
|
|
128
|
+
const { handle } = await deps.spawnWorker({
|
|
129
|
+
channel: deps.channel,
|
|
130
|
+
// Worktrees must be created from the git root, even if the lead session is in
|
|
131
|
+
// a subdirectory of the project.
|
|
132
|
+
repoRoot: gitRoot(process.cwd()) ?? process.cwd(),
|
|
133
|
+
agent,
|
|
134
|
+
role,
|
|
135
|
+
task,
|
|
136
|
+
leadHandle: deps.self,
|
|
137
|
+
});
|
|
138
|
+
return textResult(`Spawned ${agent} worker (${role}) as ${handle}. Call await_results to collect their reply.`);
|
|
139
|
+
}
|
|
140
|
+
export async function sendMessageTool(args, deps) {
|
|
141
|
+
const to = typeof args.to === 'string' ? args.to.trim() : '';
|
|
142
|
+
const body = typeof args.body === 'string' ? args.body : '';
|
|
143
|
+
if (!to || !body.trim()) {
|
|
144
|
+
return errorResult('Both "to" (a worker handle) and "body" are required.');
|
|
145
|
+
}
|
|
146
|
+
deps.channel.insertMessage({ to, from: deps.self, body });
|
|
147
|
+
return textResult(`Sent to ${to}. They'll pick it up with check_messages — it does not interrupt them.`);
|
|
148
|
+
}
|
|
149
|
+
export async function checkMessagesTool(_args, deps) {
|
|
150
|
+
const msgs = deps.channel.getUnread(deps.self);
|
|
151
|
+
if (msgs.length === 0) {
|
|
152
|
+
return textResult('No messages.');
|
|
153
|
+
}
|
|
154
|
+
return drain(msgs, deps.channel, 'Messages');
|
|
155
|
+
}
|
|
156
|
+
export function listTeamTool(deps) {
|
|
157
|
+
const members = deps.channel.listMembers();
|
|
158
|
+
if (members.length === 0) {
|
|
159
|
+
return { content: [{ type: 'text', text: 'No teammates yet. Use spawn_worker to recruit one.' }] };
|
|
160
|
+
}
|
|
161
|
+
const lines = members.map((m) => `- ${m.handle} (${m.agent}, role: ${m.role}, ${m.status})`).join('\n');
|
|
162
|
+
return { content: [{ type: 'text', text: `Team:\n${lines}` }] };
|
|
163
|
+
}
|
|
164
|
+
export function delegateTaskTool(deps, to, task) {
|
|
165
|
+
if (!deps.channel.getMember(to)) {
|
|
166
|
+
const valid = deps.channel.listMembers().map((m) => m.handle).join(', ') || '(none)';
|
|
167
|
+
return { content: [{ type: 'text', text: `No teammate "${to}". Valid: ${valid}. Use list_team / spawn_worker.` }], isError: true };
|
|
168
|
+
}
|
|
169
|
+
deps.channel.insertMessage({ to, from: deps.self, body: task, type: 'task' });
|
|
170
|
+
return { content: [{ type: 'text', text: `Delegated to ${to}. Call await_results to collect the reply.` }] };
|
|
171
|
+
}
|
|
172
|
+
export function joinTeamTool(deps, handle, role, agent) {
|
|
173
|
+
// Guard against clobbering an existing member — addMember is INSERT OR REPLACE,
|
|
174
|
+
// so joining under a taken handle (e.g. "lead" or a running worker) would
|
|
175
|
+
// silently overwrite that member's roster row and scramble addressing. Reject
|
|
176
|
+
// instead; handles must be unique on the team.
|
|
177
|
+
const existing = deps.channel.getMember(handle);
|
|
178
|
+
if (existing) {
|
|
179
|
+
return errorResult(`Handle "${handle}" is already taken (a ${existing.kind} member, role: ${existing.role}). Pick a different handle — handles must be unique on the team.`);
|
|
180
|
+
}
|
|
181
|
+
const a = agent && VALID_AGENTS.includes(agent) ? agent : 'claude';
|
|
182
|
+
deps.channel.addMember({ handle, agent: a, role, kind: 'live', status: 'active', worktree: null });
|
|
183
|
+
deps.self = handle;
|
|
184
|
+
const lines = deps.channel
|
|
185
|
+
.listMembers()
|
|
186
|
+
.map((m) => `- ${m.handle} (${m.agent}, role: ${m.role}, ${m.status})`)
|
|
187
|
+
.join('\n');
|
|
188
|
+
return textResult(`Joined the team as ${handle} (role: ${role}). You are now a LIVE member — messages addressed to ${handle} land in your inbox; call check_messages whenever you finish a unit of work so you never miss one. Team:\n${lines}`);
|
|
189
|
+
}
|
|
190
|
+
export async function awaitResultsTool(args, deps) {
|
|
191
|
+
const timeoutMs = typeof args.timeoutMs === 'number' ? args.timeoutMs : 120000;
|
|
192
|
+
const sleep = deps.sleep ?? realSleep;
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
// Poll every ~250ms for new replies; return the first non-empty batch (marking
|
|
195
|
+
// it read) or fall out to the timeout message once the deadline passes. We
|
|
196
|
+
// always poll at least once, so a pre-seeded message returns immediately.
|
|
197
|
+
for (;;) {
|
|
198
|
+
const msgs = deps.channel.getUnread(deps.self);
|
|
199
|
+
if (msgs.length > 0) {
|
|
200
|
+
return drain(msgs, deps.channel, 'Worker results');
|
|
201
|
+
}
|
|
202
|
+
if (Date.now() >= deadline) {
|
|
203
|
+
return textResult('No results within the timeout.');
|
|
204
|
+
}
|
|
205
|
+
await sleep(250);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ─── traintrack MCP server entry point ──────────────────────────────────────
|
|
3
|
+
// The executable wired into .mcp.json. Claude Code launches this as a long-lived
|
|
4
|
+
// stdio process; it opens the Channel from TRAINTRACK_CHANNEL (default
|
|
5
|
+
// <cwd>/.traintrack/channel.db) and runs the JSON-RPC readline loop until stdin
|
|
6
|
+
// closes. All the logic lives in ./mcp/server.js — this file is just the shim.
|
|
7
|
+
import { runTraintrackMcpServer } from './mcp/server.js';
|
|
8
|
+
runTraintrackMcpServer();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Build the onboarding briefing handed to every team member so it knows the
|
|
2
|
+
* team, its teammates, the coordination tools, and that it must watch for messages. */
|
|
3
|
+
export function buildBriefing(args) {
|
|
4
|
+
const others = args.roster.filter((r) => r.handle !== args.selfHandle);
|
|
5
|
+
const rosterLines = others.length
|
|
6
|
+
? others.map((r) => ` - ${r.handle} (${r.agent}, role: ${r.role})`).join('\n')
|
|
7
|
+
: ' (no other members yet — more may join)';
|
|
8
|
+
return [
|
|
9
|
+
`You are a member of the "${args.teamName}" agent team. Your role: ${args.selfRole}.`,
|
|
10
|
+
`Your teammates:`,
|
|
11
|
+
rosterLines,
|
|
12
|
+
``,
|
|
13
|
+
`How you coordinate: you run as a headless worker turn — you do NOT have MCP tools.`,
|
|
14
|
+
`Just reply in plain text. Your reply is automatically delivered back to whoever`,
|
|
15
|
+
`messaged you, so you do not need to "send" or "check" anything by hand.`,
|
|
16
|
+
``,
|
|
17
|
+
`To direct your reply at a SPECIFIC teammate instead of the sender, start your`,
|
|
18
|
+
`reply with @<their-handle-or-role> followed by your message — e.g. "@lead done: ...".`,
|
|
19
|
+
`Otherwise your reply goes back to whoever messaged you.`,
|
|
20
|
+
``,
|
|
21
|
+
`A teammate may message you at ANY time; each incoming message starts a fresh turn`,
|
|
22
|
+
`for you with that message included, so you will always see new requests.`
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Why: build the exact non-interactive argv for one headless agent turn. claude
|
|
2
|
+
// runs `--print --output-format stream-json` (the structured stream the
|
|
3
|
+
// event-parser consumes); codex runs `exec [resume <id>] --json`. Centralizing
|
|
4
|
+
// argv here keeps the codex landmines in one audited place: ALWAYS pass
|
|
5
|
+
// --dangerously-bypass-approvals-and-sandbox (else unattended MCP tool calls
|
|
6
|
+
// auto-cancel, openai/codex #24135) and resume by the CAPTURED thread id, never
|
|
7
|
+
// --last (which is global and cross-wires parallel codex workers).
|
|
8
|
+
// Local helpers: resolve the CLI binary from env or fall back to PATH.
|
|
9
|
+
function resolveClaudeCommand() {
|
|
10
|
+
return process.env.TRAINTRACK_CLAUDE_BIN ?? 'claude';
|
|
11
|
+
}
|
|
12
|
+
function resolveCodexCommand() {
|
|
13
|
+
return process.env.TRAINTRACK_CODEX_BIN ?? 'codex';
|
|
14
|
+
}
|
|
15
|
+
// claude print mode + the JSON event stream (stream-json requires --verbose).
|
|
16
|
+
const CLAUDE_STREAM_FLAGS = ['--print', '--output-format', 'stream-json', '--verbose'];
|
|
17
|
+
/** Build {command, args} for a single headless turn. The prompt is always the trailing positional. */
|
|
18
|
+
export function buildHeadlessArgv(input) {
|
|
19
|
+
const { agent, prompt, model, resumeSessionId } = input;
|
|
20
|
+
if (agent === 'claude') {
|
|
21
|
+
const command = input.claudeCommand ?? resolveClaudeCommand();
|
|
22
|
+
const args = [...CLAUDE_STREAM_FLAGS];
|
|
23
|
+
if (resumeSessionId) {
|
|
24
|
+
args.push('--resume', resumeSessionId);
|
|
25
|
+
}
|
|
26
|
+
if (model) {
|
|
27
|
+
args.push('--model', model);
|
|
28
|
+
}
|
|
29
|
+
args.push(prompt);
|
|
30
|
+
return { command, args };
|
|
31
|
+
}
|
|
32
|
+
// codex
|
|
33
|
+
const command = input.codexCommand ?? resolveCodexCommand();
|
|
34
|
+
const args = ['exec'];
|
|
35
|
+
if (resumeSessionId) {
|
|
36
|
+
args.push('resume', resumeSessionId);
|
|
37
|
+
}
|
|
38
|
+
args.push('--json');
|
|
39
|
+
if (input.codexBypassSandbox !== false) {
|
|
40
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
41
|
+
}
|
|
42
|
+
if (model) {
|
|
43
|
+
args.push('-m', model);
|
|
44
|
+
}
|
|
45
|
+
args.push(prompt);
|
|
46
|
+
return { command, args };
|
|
47
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Why: a headless agent turn (claude `--print --output-format stream-json` or
|
|
2
|
+
// codex `exec --json`) emits one JSON object per stdout line. This module is the
|
|
3
|
+
// PURE classifier for that stream — no spawning, no I/O — so it can be table-
|
|
4
|
+
// tested against fixture lines. It is the structured, in-band turn-end signal
|
|
5
|
+
// that replaces PTY idle-guessing (see project_headless_worker_pivot): claude's
|
|
6
|
+
// {type:'result'} and codex's {type:'turn.completed'} are authoritative ACKs.
|
|
7
|
+
//
|
|
8
|
+
// Ported from the abandoned Rust conductor's runtime/event_parser.rs.
|
|
9
|
+
export function createTurnParseState(provider) {
|
|
10
|
+
return {
|
|
11
|
+
provider,
|
|
12
|
+
finalText: '',
|
|
13
|
+
streamedText: '',
|
|
14
|
+
isError: false,
|
|
15
|
+
ended: false
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse one NDJSON line to a plain object. Returns null for blank or malformed
|
|
20
|
+
* lines — NEVER throws (a CLI can interleave non-JSON banner lines on stdout).
|
|
21
|
+
*/
|
|
22
|
+
export function parseJsonLine(line) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(trimmed);
|
|
29
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function str(v) {
|
|
36
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
37
|
+
}
|
|
38
|
+
function num(v) {
|
|
39
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
40
|
+
}
|
|
41
|
+
function asRecord(v) {
|
|
42
|
+
return v && typeof v === 'object' && !Array.isArray(v)
|
|
43
|
+
? v
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
/** True when this event is the authoritative turn-end (the ACK). */
|
|
47
|
+
export function isTurnEndEvent(provider, evt) {
|
|
48
|
+
const type = str(evt.type);
|
|
49
|
+
return provider === 'claude' ? type === 'result' : type === 'turn.completed';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The provider session id to pin for resume, if this event carries one.
|
|
53
|
+
* claude: the {type:'system',subtype:'init'} event (also present on `result`).
|
|
54
|
+
* codex: the {type:'thread.started'} event (turn 1 only) — load-bearing for
|
|
55
|
+
* per-agent resume; using --last with >1 codex worker cross-wires threads.
|
|
56
|
+
*/
|
|
57
|
+
export function extractSessionId(provider, evt) {
|
|
58
|
+
if (provider === 'claude') {
|
|
59
|
+
const type = str(evt.type);
|
|
60
|
+
if (type === 'system' && str(evt.subtype) === 'init') {
|
|
61
|
+
return str(evt.session_id);
|
|
62
|
+
}
|
|
63
|
+
if (type === 'result') {
|
|
64
|
+
return str(evt.session_id);
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
// codex
|
|
69
|
+
if (str(evt.type) === 'thread.started') {
|
|
70
|
+
return str(evt.thread_id) ?? str(evt.session_id);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Incremental assistant text from a streaming event, else undefined. */
|
|
75
|
+
export function extractTextDelta(provider, evt) {
|
|
76
|
+
if (provider === 'claude') {
|
|
77
|
+
// {type:'assistant', message:{content:[{type:'text', text:'...'}]}}
|
|
78
|
+
if (str(evt.type) !== 'assistant') {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const message = asRecord(evt.message);
|
|
82
|
+
const content = message?.content;
|
|
83
|
+
if (!Array.isArray(content)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const block of content) {
|
|
88
|
+
const rec = asRecord(block);
|
|
89
|
+
if (rec && str(rec.type) === 'text') {
|
|
90
|
+
const t = str(rec.text);
|
|
91
|
+
if (t) {
|
|
92
|
+
parts.push(t);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return parts.length ? parts.join('') : undefined;
|
|
97
|
+
}
|
|
98
|
+
// codex: {type:'item.completed', item:{type:'agent_message', text:'...'}}
|
|
99
|
+
if (str(evt.type) !== 'item.completed') {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const item = asRecord(evt.item);
|
|
103
|
+
const itemType = item ? (str(item.type) ?? str(item.item_type)) : undefined;
|
|
104
|
+
if (item && (itemType === 'agent_message' || itemType === 'assistant_message')) {
|
|
105
|
+
return str(item.text);
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
function applyTurnEnd(state, evt) {
|
|
110
|
+
state.ended = true;
|
|
111
|
+
if (state.provider === 'claude') {
|
|
112
|
+
state.authoritativeText = str(evt.result) ?? state.authoritativeText;
|
|
113
|
+
state.isError = evt.is_error === true || str(evt.subtype)?.startsWith('error') === true;
|
|
114
|
+
const usage = asRecord(evt.usage);
|
|
115
|
+
state.tokensIn = num(usage?.input_tokens) ?? state.tokensIn;
|
|
116
|
+
state.tokensOut = num(usage?.output_tokens) ?? state.tokensOut;
|
|
117
|
+
state.costUsd = num(evt.total_cost_usd) ?? state.costUsd;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// codex turn.completed
|
|
121
|
+
const status = str(evt.status);
|
|
122
|
+
state.isError = status === 'failed' || status === 'interrupted';
|
|
123
|
+
const usage = asRecord(evt.usage);
|
|
124
|
+
state.tokensIn = num(usage?.input_tokens) ?? state.tokensIn;
|
|
125
|
+
state.tokensOut = num(usage?.output_tokens) ?? state.tokensOut;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Reduce one stdout line into the accumulator state, mutating it in place.
|
|
130
|
+
* Returns the streaming text delta produced by this line (if any) so the caller
|
|
131
|
+
* can forward live tokens to the UI without re-parsing.
|
|
132
|
+
*/
|
|
133
|
+
export function reduceLine(state, line) {
|
|
134
|
+
const evt = parseJsonLine(line);
|
|
135
|
+
if (!evt) {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
const sessionId = extractSessionId(state.provider, evt);
|
|
139
|
+
if (sessionId && !state.sessionId) {
|
|
140
|
+
state.sessionId = sessionId;
|
|
141
|
+
}
|
|
142
|
+
let delta;
|
|
143
|
+
if (!isTurnEndEvent(state.provider, evt)) {
|
|
144
|
+
delta = extractTextDelta(state.provider, evt);
|
|
145
|
+
if (delta) {
|
|
146
|
+
state.streamedText += delta;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
applyTurnEnd(state, evt);
|
|
151
|
+
}
|
|
152
|
+
return delta ? { delta } : {};
|
|
153
|
+
}
|
|
154
|
+
/** Snapshot the accumulator into a turn result (call after the stream closes). */
|
|
155
|
+
export function finalizeTurn(state) {
|
|
156
|
+
return {
|
|
157
|
+
finalText: state.authoritativeText ?? state.streamedText,
|
|
158
|
+
sessionId: state.sessionId,
|
|
159
|
+
tokensIn: state.tokensIn,
|
|
160
|
+
tokensOut: state.tokensOut,
|
|
161
|
+
costUsd: state.costUsd,
|
|
162
|
+
isError: state.isError,
|
|
163
|
+
ended: state.ended
|
|
164
|
+
};
|
|
165
|
+
}
|