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.
@@ -0,0 +1,190 @@
1
+ // ─── traintrack headless worker ──────────────────────────────────────────────
2
+ // A long-lived loop that makes ONE agent a reliable, auto-responding teammate.
3
+ // Unlike PTY injection (best-effort, research-proven unreliable), this drains the
4
+ // agent's durable inbox (the local SQLite Channel) and runs each turn as a fresh
5
+ // HEADLESS process (claude --print / codex exec resume), using the structured
6
+ // turn-end event as the ACK. The receiving agent therefore "sees" messages with
7
+ // zero human input and on a guaranteed schedule.
8
+ //
9
+ // Ported from $TT/src/cli/headless-worker.ts and REPOINTED off Orca's
10
+ // RuntimeClient (RPC) onto the local Channel:
11
+ // orchestration.check {unread} → channel.getUnread(handle) + channel.markRead(ids)
12
+ // orchestration.send {to,from,body} → channel.insertMessage({to,from,body})
13
+ // team.join {...} → channel.addMember({handle, agent, role, kind:'headless', ...})
14
+ // team.list → channel.listMembers()
15
+ // No RPC, no engine — the worker takes a Channel directly.
16
+ import { buildHeadlessArgv } from '../runner/argv.js';
17
+ import { runHeadlessTurn } from '../runner/turn-runner.js';
18
+ import { buildBriefing } from '../onboarding/briefing.js';
19
+ /** Build the team briefing from the channel's CURRENT roster. Called every loop
20
+ * iteration so a worker's prompt reflects teammates who joined after it spawned. */
21
+ export function buildRosterBriefing(channel, selfHandle, selfRole, teamName = 'team') {
22
+ const roster = channel.listMembers().map((m) => ({
23
+ handle: m.handle,
24
+ role: m.role,
25
+ agent: m.agent,
26
+ kind: m.kind,
27
+ }));
28
+ return buildBriefing({ teamName, selfHandle, selfRole, roster });
29
+ }
30
+ /** Build the turn prompt from the drained inbox messages (port of the rust build_prompt). */
31
+ export function buildWorkerPrompt(agent, messages, briefing) {
32
+ const lines = messages.map((m) => `From ${m.from}: ${m.body}`).join('\n');
33
+ const body = [
34
+ `You are the "${agent}" agent in a traintrack multi-agent workspace, coordinating with teammates over a shared message channel.`,
35
+ `The following message(s) just arrived in your inbox. Read them and reply — your reply is delivered back to the sender(s).`,
36
+ '',
37
+ '--- Messages ---',
38
+ lines,
39
+ '--- End ---',
40
+ '',
41
+ 'Respond now.'
42
+ ].join('\n');
43
+ if (briefing) {
44
+ return `${briefing}\n\n${body}`;
45
+ }
46
+ return body;
47
+ }
48
+ /**
49
+ * Resolve a direct peer address from a worker's reply text. If `text` starts with
50
+ * `@`, the leading token (up to the first whitespace) is matched case-insensitively
51
+ * as a substring against each member's `handle` or `role` (excluding `selfHandle`).
52
+ * On a match, returns `{ to: matchedHandle, body: <rest of text> }`. If the text has
53
+ * no leading `@`, or the token matches no member, returns `null` (caller falls back
54
+ * to replying to the original sender — the message is never dropped). Ported from the
55
+ * `@name` parse in traintrack-desktop's session.ts.
56
+ */
57
+ export function resolvePeerAddress(text, members, selfHandle) {
58
+ if (!text.startsWith('@')) {
59
+ return null;
60
+ }
61
+ const ws = text.search(/\s/);
62
+ const token = (ws === -1 ? text.slice(1) : text.slice(1, ws)).trim();
63
+ const body = ws === -1 ? '' : text.slice(ws + 1).trim();
64
+ if (!token) {
65
+ return null;
66
+ }
67
+ const needle = token.toLowerCase();
68
+ const match = members.find((m) => m.handle !== selfHandle &&
69
+ (m.handle.toLowerCase().includes(needle) || m.role.toLowerCase().includes(needle)));
70
+ return match ? { to: match.handle, body } : null;
71
+ }
72
+ /**
73
+ * One drain → headless turn → reply cycle. Pulls this worker's unread inbox from
74
+ * the channel, runs a single headless agent turn with those messages spliced in,
75
+ * posts the agent's reply back to each distinct sender, and marks the drained
76
+ * messages read. Returns what happened (for logging + tests) including the
77
+ * (possibly new) session id to carry forward.
78
+ */
79
+ export async function runWorkerCycle(deps) {
80
+ const { channel, handle, agent, cwd, model, runTurn, print, briefing } = deps;
81
+ const messages = channel.getUnread(handle);
82
+ if (messages.length === 0) {
83
+ return { processed: 0, sessionId: deps.sessionId, repliedTo: [] };
84
+ }
85
+ print?.(`[worker] ${agent} draining ${messages.length} message(s)`);
86
+ const prompt = buildWorkerPrompt(agent, messages, briefing);
87
+ const turn = await runTurn({
88
+ provider: agent,
89
+ prompt,
90
+ cwd,
91
+ model,
92
+ resumeSessionId: deps.sessionId
93
+ });
94
+ const reply = turn.finalText.trim();
95
+ const senders = [...new Set(messages.map((m) => m.from).filter(Boolean))];
96
+ // If the reply opens with `@<handle-or-role>`, route the rest to that teammate
97
+ // instead of the original sender(s). No `@` / no match → reply to senders as usual.
98
+ const peer = reply ? resolvePeerAddress(reply, channel.listMembers(), handle) : null;
99
+ let repliedTo = [];
100
+ if (reply && peer) {
101
+ channel.insertMessage({ to: peer.to, from: handle, body: peer.body, type: 'status' });
102
+ repliedTo = [peer.to];
103
+ print?.(`[worker] ${agent} addressed ${peer.to}`);
104
+ }
105
+ else if (reply) {
106
+ for (const to of senders) {
107
+ channel.insertMessage({ to, from: handle, body: reply, type: 'status' });
108
+ }
109
+ repliedTo = senders;
110
+ print?.(`[worker] ${agent} replied to ${senders.join(', ')}`);
111
+ }
112
+ else {
113
+ print?.(`[worker] ${agent} produced no reply text (isError=${turn.isError})`);
114
+ }
115
+ // ACK the drained messages so they are not re-processed next cycle. Done after
116
+ // the turn + reply so a crash mid-turn leaves them unread (re-tried) rather
117
+ // than silently dropped.
118
+ channel.markRead(messages.map((m) => m.id));
119
+ return {
120
+ processed: messages.length,
121
+ reply: reply || undefined,
122
+ sessionId: turn.sessionId ?? deps.sessionId,
123
+ repliedTo
124
+ };
125
+ }
126
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
127
+ /** Default turn runner: build the argv then spawn a real headless turn. */
128
+ const realTurnRunner = async (input) => {
129
+ const { command, args } = buildHeadlessArgv({
130
+ agent: input.provider,
131
+ prompt: input.prompt,
132
+ model: input.model,
133
+ resumeSessionId: input.resumeSessionId
134
+ });
135
+ const outcome = await runHeadlessTurn({
136
+ provider: input.provider,
137
+ command,
138
+ args,
139
+ cwd: input.cwd
140
+ });
141
+ return { finalText: outcome.finalText, sessionId: outcome.sessionId, isError: outcome.isError };
142
+ };
143
+ /**
144
+ * Run the worker loop until the process is killed (or one cycle when once=true).
145
+ * On start it registers itself in the channel's roster (addMember, kind=headless),
146
+ * reads the roster (listMembers), and builds the team briefing ONCE — the briefing
147
+ * is then prepended to every per-turn prompt.
148
+ */
149
+ export async function runWorker(opts) {
150
+ const print = opts.print ?? ((msg) => process.stderr.write(`${msg}\n`));
151
+ const sleep = opts.sleep ?? defaultSleep;
152
+ const pollMs = opts.pollMs ?? 3000;
153
+ const runTurn = opts.runTurn ?? realTurnRunner;
154
+ // Self-register in the channel roster so teammates discover this worker.
155
+ const self = {
156
+ handle: opts.handle,
157
+ agent: opts.agent,
158
+ role: opts.role,
159
+ kind: opts.kind ?? 'headless',
160
+ status: 'active',
161
+ worktree: opts.cwd
162
+ };
163
+ opts.channel.addMember(self);
164
+ print(`[worker] online as ${opts.handle} (agent=${opts.agent}, role=${opts.role}, cwd=${opts.cwd}, poll=${pollMs}ms)`);
165
+ let sessionId;
166
+ for (;;) {
167
+ try {
168
+ const briefing = buildRosterBriefing(opts.channel, opts.handle, opts.role);
169
+ const cycle = await runWorkerCycle({
170
+ channel: opts.channel,
171
+ handle: opts.handle,
172
+ agent: opts.agent,
173
+ cwd: opts.cwd,
174
+ model: opts.model,
175
+ runTurn,
176
+ sessionId,
177
+ print,
178
+ briefing
179
+ });
180
+ sessionId = cycle.sessionId;
181
+ }
182
+ catch (err) {
183
+ print(`[worker] cycle error: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ if (opts.once) {
186
+ return;
187
+ }
188
+ await sleep(pollMs);
189
+ }
190
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|resume|clear",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex",
10
+ "async": false
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [
5
+ {
6
+ "command": "./hooks/run-hook.cmd session-start"
7
+ }
8
+ ]
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|clear|compact",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
10
+ "async": false
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,46 @@
1
+ : << 'CMDBLOCK'
2
+ @echo off
3
+ REM Cross-platform polyglot wrapper for hook scripts.
4
+ REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
5
+ REM On Unix: the shell interprets this as a script (: is a no-op in bash).
6
+ REM
7
+ REM Hook scripts use extensionless filenames (e.g. "session-start" not
8
+ REM "session-start.sh") so Claude Code's Windows auto-detection -- which
9
+ REM prepends "bash" to any command containing .sh -- doesn't interfere.
10
+ REM
11
+ REM Usage: run-hook.cmd <script-name> [args...]
12
+
13
+ if "%~1"=="" (
14
+ echo run-hook.cmd: missing script name >&2
15
+ exit /b 1
16
+ )
17
+
18
+ set "HOOK_DIR=%~dp0"
19
+
20
+ REM Try Git for Windows bash in standard locations
21
+ if exist "C:\Program Files\Git\bin\bash.exe" (
22
+ "C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
23
+ exit /b %ERRORLEVEL%
24
+ )
25
+ if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
26
+ "C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
27
+ exit /b %ERRORLEVEL%
28
+ )
29
+
30
+ REM Try bash on PATH (e.g. user-installed Git Bash, MSYS2, Cygwin)
31
+ where bash >nul 2>nul
32
+ if %ERRORLEVEL% equ 0 (
33
+ bash "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
34
+ exit /b %ERRORLEVEL%
35
+ )
36
+
37
+ REM No bash found - exit silently rather than error
38
+ REM (plugin still works, just without SessionStart context injection)
39
+ exit /b 0
40
+ CMDBLOCK
41
+
42
+ # Unix: run the named script directly
43
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
44
+ SCRIPT_NAME="$1"
45
+ shift
46
+ exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook for the traintrack plugin.
3
+ # Injects "you are the team lead" awareness so the model knows it can spawn,
4
+ # delegate to, and collect from a team of worker agents via the traintrack MCP tools.
5
+
6
+ set -euo pipefail
7
+
8
+ # The traintrack awareness content injected into the session context.
9
+ traintrack_content="You have the \`traintrack\` multi-agent coordination tools (list_team, spawn_worker, delegate_task, await_results, send_message, check_messages). You are the team LEAD. When the user asks you to build something sizable, you can spawn worker agents to parallelize: spawn_worker(agent, role, task) for each part, then await_results() to collect their work, then synthesize. Use them when delegation helps; do simple tasks yourself. You can run MULTIPLE rounds: spawn or delegate to teammates by role, call await_results to collect their replies, then delegate follow-up tasks based on what came back and collect again, before synthesizing your final answer. Give each teammate a clear role and keep the team small. If a human adds your session to an EXISTING team (rather than you leading), call join_team(handle, role) first, then check_messages — teammates may message you at any time."
10
+
11
+ # Escape a string for embedding inside a JSON string literal. Each ${s//old/new}
12
+ # is a single C-level pass — orders of magnitude faster than a char loop.
13
+ escape_for_json() {
14
+ local s="$1"
15
+ s="${s//\\/\\\\}"
16
+ s="${s//\"/\\\"}"
17
+ s="${s//$'\n'/\\n}"
18
+ s="${s//$'\r'/\\r}"
19
+ s="${s//$'\t'/\\t}"
20
+ printf '%s' "$s"
21
+ }
22
+
23
+ traintrack_escaped=$(escape_for_json "$traintrack_content")
24
+ session_context="<EXTREMELY_IMPORTANT>\n${traintrack_escaped}\n</EXTREMELY_IMPORTANT>"
25
+
26
+ # Output context injection as JSON.
27
+ # Cursor hooks expect additional_context (snake_case).
28
+ # Claude Code hooks expect hookSpecificOutput.additionalContext (nested).
29
+ # Copilot CLI (v1.0.11+) and others expect additionalContext (top-level, SDK standard).
30
+ #
31
+ # Uses printf instead of heredoc to work around bash 5.3+ heredoc hang.
32
+ # See: https://github.com/obra/superpowers/issues/571
33
+ if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
34
+ # Cursor sets CURSOR_PLUGIN_ROOT (may also set CLAUDE_PLUGIN_ROOT)
35
+ printf '{\n "additional_context": "%s"\n}\n' "$session_context" | cat
36
+ elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
37
+ # Claude Code sets CLAUDE_PLUGIN_ROOT without COPILOT_CLI
38
+ printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" | cat
39
+ else
40
+ # Copilot CLI (sets COPILOT_CLI=1) or unknown platform — SDK standard format
41
+ printf '{\n "additionalContext": "%s"\n}\n' "$session_context" | cat
42
+ fi
43
+
44
+ exit 0
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Codex SessionStart hook for traintrack plugin.
3
+ # Injects "you are the team lead" awareness so the model knows it can spawn,
4
+ # delegate to, and collect from a team of worker agents via the traintrack MCP tools.
5
+
6
+ set -euo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
10
+
11
+ traintrack_content="You have the \`traintrack\` multi-agent coordination tools (list_team, spawn_worker, delegate_task, await_results, send_message, check_messages). You are the team LEAD. When the user asks you to build something sizable, you can spawn worker agents to parallelize: spawn_worker(agent, role, task) for each part, then await_results() to collect their work, then synthesize. Use them when delegation helps; do simple tasks yourself. You can run MULTIPLE rounds: spawn or delegate to teammates by role, call await_results to collect their replies, then delegate follow-up tasks based on what came back and collect again, before synthesizing your final answer. Give each teammate a clear role and keep the team small. If a human adds your session to an EXISTING team (rather than you leading), call join_team(handle, role) first, then check_messages — teammates may message you at any time."
12
+
13
+ escape_for_json() {
14
+ local s="$1"
15
+ s="${s//\\/\\\\}"
16
+ s="${s//\"/\\\"}"
17
+ s="${s//$'\n'/\\n}"
18
+ s="${s//$'\r'/\\r}"
19
+ s="${s//$'\t'/\\t}"
20
+ printf '%s' "$s"
21
+ }
22
+
23
+ traintrack_escaped=$(escape_for_json "$traintrack_content")
24
+ session_context="<EXTREMELY_IMPORTANT>\n${traintrack_escaped}\n</EXTREMELY_IMPORTANT>"
25
+
26
+ printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" | cat
27
+
28
+ exit 0
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "traintrack",
3
+ "version": "2.0.0",
4
+ "publishConfig": { "access": "public" },
5
+ "description": "Multi-agent coordination for coding agents — a lead spawns worker agents, delegates tasks, and collects results over a local channel. One command wires it into Claude Code, Codex, Cursor, and OpenCode.",
6
+ "keywords": [
7
+ "mcp",
8
+ "multi-agent",
9
+ "claude",
10
+ "codex",
11
+ "cursor",
12
+ "opencode",
13
+ "agent",
14
+ "coordination",
15
+ "a2a",
16
+ "orchestration",
17
+ "ai"
18
+ ],
19
+ "homepage": "https://github.com/OKKHALIL3/traintrack#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/OKKHALIL3/traintrack/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/OKKHALIL3/traintrack.git"
26
+ },
27
+ "author": "Omar Khalil",
28
+ "license": "Apache-2.0",
29
+ "type": "module",
30
+ "main": "dist/index.js",
31
+ "exports": { ".": "./dist/index.js" },
32
+ "bin": { "traintrack": "dist/cli.js" },
33
+ "files": ["dist", "hooks"],
34
+ "engines": { "node": ">=18" },
35
+ "os": ["darwin", "linux"],
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json && npm run postbuild",
38
+ "postbuild": "chmod +x dist/cli.js",
39
+ "prepare": "npm run build",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc --noEmit"
42
+ },
43
+ "dependencies": { "better-sqlite3": "^11.8.0" },
44
+ "devDependencies": {
45
+ "@types/better-sqlite3": "^7.6.11",
46
+ "@types/node": "^22.10.0",
47
+ "typescript": "^5.7.0",
48
+ "vitest": "^2.1.0",
49
+ "eslint": "^9.0.0",
50
+ "prettier": "^3.4.0"
51
+ }
52
+ }