tlc-claude-code 2.8.0 → 2.9.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/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +12 -0
- package/.claude/commands/tlc/build.md +69 -22
- package/.claude/commands/tlc/cleanup.md +13 -1
- package/.claude/commands/tlc/coverage.md +12 -0
- package/.claude/commands/tlc/discuss.md +12 -0
- package/.claude/commands/tlc/docs.md +13 -1
- package/.claude/commands/tlc/edge-cases.md +13 -1
- package/.claude/commands/tlc/plan.md +12 -0
- package/.claude/commands/tlc/preflight.md +12 -0
- package/.claude/commands/tlc/refactor.md +12 -0
- package/.claude/commands/tlc/review-pr.md +13 -1
- package/.claude/commands/tlc/review.md +12 -0
- package/.claude/commands/tlc/security.md +13 -1
- package/.claude/commands/tlc/status.md +20 -1
- package/.claude/commands/tlc/verify.md +12 -0
- package/.claude/commands/tlc/watchci.md +12 -0
- package/package.json +1 -1
- package/server/lib/orchestration/completion-checker.js +52 -2
- package/server/lib/orchestration/completion-checker.test.js +64 -0
- package/server/lib/orchestration/session-status.js +28 -4
- package/server/lib/orchestration/session-status.test.js +44 -1
- package/server/lib/orchestration/skill-dispatcher.js +270 -0
- package/server/lib/orchestration/skill-dispatcher.test.js +449 -0
|
@@ -49,6 +49,7 @@ describe('completion-checker', () => {
|
|
|
49
49
|
expect(result).toEqual({
|
|
50
50
|
completions: [],
|
|
51
51
|
failures: [],
|
|
52
|
+
actionable: [],
|
|
52
53
|
stillRunning: 0,
|
|
53
54
|
});
|
|
54
55
|
expect(fetch).not.toHaveBeenCalled();
|
|
@@ -74,6 +75,7 @@ describe('completion-checker', () => {
|
|
|
74
75
|
expect(result).toEqual({
|
|
75
76
|
completions: [makeSession()],
|
|
76
77
|
failures: [],
|
|
78
|
+
actionable: [],
|
|
77
79
|
stillRunning: 0,
|
|
78
80
|
});
|
|
79
81
|
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
@@ -97,6 +99,7 @@ describe('completion-checker', () => {
|
|
|
97
99
|
expect(result).toEqual({
|
|
98
100
|
completions: [],
|
|
99
101
|
failures: [{ ...makeSession(), reason: 'Tests failed' }],
|
|
102
|
+
actionable: [],
|
|
100
103
|
stillRunning: 0,
|
|
101
104
|
});
|
|
102
105
|
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
@@ -131,11 +134,70 @@ describe('completion-checker', () => {
|
|
|
131
134
|
expect(result).toEqual({
|
|
132
135
|
completions: [completed],
|
|
133
136
|
failures: [{ ...failed, reason: 'Rejected' }],
|
|
137
|
+
actionable: [],
|
|
134
138
|
stillRunning: 1,
|
|
135
139
|
});
|
|
136
140
|
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([running]);
|
|
137
141
|
});
|
|
138
142
|
|
|
143
|
+
it.each([
|
|
144
|
+
['stuck', { status: 'stuck', actionable: true, suggestedAction: 'retry' }],
|
|
145
|
+
['budget_exhausted', { status: 'budget_exhausted', actionable: true, suggestedAction: 'retry' }],
|
|
146
|
+
])('reports actionable monitor state %s', async (state, expected) => {
|
|
147
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
148
|
+
const session = makeSession();
|
|
149
|
+
writeSessionsFile(activeSessionsPath, [session]);
|
|
150
|
+
|
|
151
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
152
|
+
ok: true,
|
|
153
|
+
json: async () => ({ status: state }),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await checkCompletions({
|
|
157
|
+
activeSessionsPath,
|
|
158
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
159
|
+
fetch,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
completions: [],
|
|
164
|
+
failures: [],
|
|
165
|
+
actionable: [{ ...session, ...expected }],
|
|
166
|
+
stillRunning: 0,
|
|
167
|
+
});
|
|
168
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it.each([
|
|
172
|
+
['crashed', 'session crashed and needs investigation'],
|
|
173
|
+
['errored', 'session errored and needs investigation'],
|
|
174
|
+
['loop_detected', 'session entered a loop and needs investigation'],
|
|
175
|
+
['timed_out', 'session timed out and needs investigation'],
|
|
176
|
+
])('reports failure monitor state %s', async (state, failureReason) => {
|
|
177
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
178
|
+
const session = makeSession();
|
|
179
|
+
writeSessionsFile(activeSessionsPath, [session]);
|
|
180
|
+
|
|
181
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
182
|
+
ok: true,
|
|
183
|
+
json: async () => ({ status: state }),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const result = await checkCompletions({
|
|
187
|
+
activeSessionsPath,
|
|
188
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
189
|
+
fetch,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
completions: [],
|
|
194
|
+
failures: [{ ...session, status: state, actionable: false, failureReason }],
|
|
195
|
+
actionable: [],
|
|
196
|
+
stillRunning: 0,
|
|
197
|
+
});
|
|
198
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
139
201
|
it('returns an error result when the orchestrator is unreachable', async () => {
|
|
140
202
|
const activeSessionsPath = makeActiveSessionsPath();
|
|
141
203
|
const session = makeSession();
|
|
@@ -152,6 +214,7 @@ describe('completion-checker', () => {
|
|
|
152
214
|
expect(result).toEqual({
|
|
153
215
|
completions: [],
|
|
154
216
|
failures: [],
|
|
217
|
+
actionable: [],
|
|
155
218
|
stillRunning: -1,
|
|
156
219
|
error: 'orchestrator unreachable',
|
|
157
220
|
});
|
|
@@ -170,6 +233,7 @@ describe('completion-checker', () => {
|
|
|
170
233
|
expect(result).toEqual({
|
|
171
234
|
completions: [],
|
|
172
235
|
failures: [],
|
|
236
|
+
actionable: [],
|
|
173
237
|
stillRunning: 0,
|
|
174
238
|
});
|
|
175
239
|
expect(fs.existsSync(activeSessionsPath)).toBe(false);
|
|
@@ -49,6 +49,24 @@ function buildSummary(session) {
|
|
|
49
49
|
return titleCaseStatus(session.status);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function getSessionSkill(session) {
|
|
53
|
+
return session.metadata?.skill || session.skill || '-';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getMonitorState(session) {
|
|
57
|
+
return session.monitorState || session.monitor?.state || session.metadata?.monitorState || '-';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasAlert(session) {
|
|
61
|
+
const monitorState = String(getMonitorState(session)).toLowerCase();
|
|
62
|
+
return ['stuck', 'errored', 'crashed'].includes(monitorState);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSessionId(session) {
|
|
66
|
+
const id = session.id || '-';
|
|
67
|
+
return hasAlert(session) ? `[!] ${id}` : id;
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/**
|
|
53
71
|
* Format rows into an aligned CLI table.
|
|
54
72
|
* @param {string[]} headers
|
|
@@ -108,11 +126,13 @@ function formatSessionStatus({ sessions } = {}) {
|
|
|
108
126
|
sections.push([
|
|
109
127
|
'Running Sessions',
|
|
110
128
|
formatTable(
|
|
111
|
-
['ID', 'Project', 'Status', 'Elapsed', 'Task'],
|
|
129
|
+
['ID', 'Skill', 'Project', 'Status', 'Monitor', 'Elapsed', 'Task'],
|
|
112
130
|
running.map((session) => [
|
|
113
|
-
session
|
|
131
|
+
formatSessionId(session),
|
|
132
|
+
getSessionSkill(session),
|
|
114
133
|
session.project || '-',
|
|
115
134
|
session.status || '-',
|
|
135
|
+
getMonitorState(session),
|
|
116
136
|
formatElapsed(session.created_at),
|
|
117
137
|
session.command || '-',
|
|
118
138
|
])
|
|
@@ -125,11 +145,13 @@ function formatSessionStatus({ sessions } = {}) {
|
|
|
125
145
|
sections.push([
|
|
126
146
|
'Completed Sessions',
|
|
127
147
|
formatTable(
|
|
128
|
-
['ID', 'Project', 'Status', 'Summary'],
|
|
148
|
+
['ID', 'Skill', 'Project', 'Status', 'Monitor', 'Summary'],
|
|
129
149
|
completed.map((session) => [
|
|
130
|
-
session
|
|
150
|
+
formatSessionId(session),
|
|
151
|
+
getSessionSkill(session),
|
|
131
152
|
session.project || '-',
|
|
132
153
|
session.status || '-',
|
|
154
|
+
getMonitorState(session),
|
|
133
155
|
buildSummary(session),
|
|
134
156
|
])
|
|
135
157
|
),
|
|
@@ -144,4 +166,6 @@ module.exports = {
|
|
|
144
166
|
formatSessionStatus,
|
|
145
167
|
formatElapsed,
|
|
146
168
|
buildSummary,
|
|
169
|
+
getSessionSkill,
|
|
170
|
+
getMonitorState,
|
|
147
171
|
};
|
|
@@ -19,6 +19,8 @@ describe('session-status', () => {
|
|
|
19
19
|
id: 'sess-101',
|
|
20
20
|
project: 'alpha',
|
|
21
21
|
status: 'running',
|
|
22
|
+
metadata: { skill: 'review' },
|
|
23
|
+
monitorState: 'idle',
|
|
22
24
|
created_at: '2026-03-30T11:15:00Z',
|
|
23
25
|
command: 'Implement API',
|
|
24
26
|
},
|
|
@@ -27,7 +29,9 @@ describe('session-status', () => {
|
|
|
27
29
|
|
|
28
30
|
expect(output).toContain('Running Sessions');
|
|
29
31
|
expect(output).toContain('sess-101');
|
|
32
|
+
expect(output).toContain('review');
|
|
30
33
|
expect(output).toContain('alpha');
|
|
34
|
+
expect(output).toContain('idle');
|
|
31
35
|
expect(output).toContain('Implement API');
|
|
32
36
|
expect(output).toContain('45m');
|
|
33
37
|
expect(output).toContain('Attach: tlc-core attach sess-101');
|
|
@@ -40,6 +44,8 @@ describe('session-status', () => {
|
|
|
40
44
|
id: 'sess-202',
|
|
41
45
|
project: 'beta',
|
|
42
46
|
status: 'completed',
|
|
47
|
+
metadata: { skill: 'build' },
|
|
48
|
+
monitor: { state: 'completed' },
|
|
43
49
|
created_at: '2026-03-30T10:00:00Z',
|
|
44
50
|
command: 'Add tests',
|
|
45
51
|
},
|
|
@@ -48,7 +54,9 @@ describe('session-status', () => {
|
|
|
48
54
|
|
|
49
55
|
expect(output).toContain('Completed Sessions');
|
|
50
56
|
expect(output).toContain('sess-202');
|
|
57
|
+
expect(output).toContain('build');
|
|
51
58
|
expect(output).toContain('beta');
|
|
59
|
+
expect(output).toContain('completed');
|
|
52
60
|
expect(output).toContain('Completed: Add tests');
|
|
53
61
|
expect(output).toContain('Attach: tlc-core attach sess-202');
|
|
54
62
|
});
|
|
@@ -60,6 +68,8 @@ describe('session-status', () => {
|
|
|
60
68
|
id: 'sess-run',
|
|
61
69
|
project: 'gamma',
|
|
62
70
|
status: 'running',
|
|
71
|
+
metadata: { skill: 'qa' },
|
|
72
|
+
monitorState: 'idle',
|
|
63
73
|
created_at: '2026-03-30T11:50:00Z',
|
|
64
74
|
command: 'Refactor worker',
|
|
65
75
|
},
|
|
@@ -67,6 +77,8 @@ describe('session-status', () => {
|
|
|
67
77
|
id: 'sess-done',
|
|
68
78
|
project: 'delta',
|
|
69
79
|
status: 'failed',
|
|
80
|
+
metadata: { skill: 'review' },
|
|
81
|
+
monitorState: 'errored',
|
|
70
82
|
created_at: '2026-03-30T10:30:00Z',
|
|
71
83
|
command: 'Fix flaky test',
|
|
72
84
|
},
|
|
@@ -77,6 +89,7 @@ describe('session-status', () => {
|
|
|
77
89
|
expect(output).toContain('Completed Sessions');
|
|
78
90
|
expect(output).toContain('10m');
|
|
79
91
|
expect(output).toContain('Failed: Fix flaky test');
|
|
92
|
+
expect(output).toContain('[!] sess-done');
|
|
80
93
|
});
|
|
81
94
|
|
|
82
95
|
it('returns null for empty sessions', () => {
|
|
@@ -91,6 +104,8 @@ describe('session-status', () => {
|
|
|
91
104
|
id: 's-1',
|
|
92
105
|
project: 'short',
|
|
93
106
|
status: 'running',
|
|
107
|
+
metadata: { skill: 'lint' },
|
|
108
|
+
monitorState: 'idle',
|
|
94
109
|
created_at: '2026-03-30T11:00:00Z',
|
|
95
110
|
command: 'Task A',
|
|
96
111
|
},
|
|
@@ -98,6 +113,8 @@ describe('session-status', () => {
|
|
|
98
113
|
id: 'session-222',
|
|
99
114
|
project: 'much-longer-project',
|
|
100
115
|
status: 'running',
|
|
116
|
+
metadata: { skill: 'security-review' },
|
|
117
|
+
monitorState: 'stuck',
|
|
101
118
|
created_at: '2026-03-30T11:30:00Z',
|
|
102
119
|
command: 'Task B',
|
|
103
120
|
},
|
|
@@ -107,7 +124,7 @@ describe('session-status', () => {
|
|
|
107
124
|
const lines = output.split('\n');
|
|
108
125
|
const header = lines.find((line) => line.startsWith('ID'));
|
|
109
126
|
const firstRow = lines.find((line) => line.startsWith('s-1'));
|
|
110
|
-
const secondRow = lines.find((line) => line.
|
|
127
|
+
const secondRow = lines.find((line) => line.includes('session-222'));
|
|
111
128
|
|
|
112
129
|
expect(header).toBeTruthy();
|
|
113
130
|
expect(firstRow).toBeTruthy();
|
|
@@ -115,6 +132,7 @@ describe('session-status', () => {
|
|
|
115
132
|
|
|
116
133
|
const projectStart = header.indexOf('Project');
|
|
117
134
|
const statusStart = header.indexOf('Status');
|
|
135
|
+
const monitorStart = header.indexOf('Monitor');
|
|
118
136
|
const elapsedStart = header.indexOf('Elapsed');
|
|
119
137
|
const taskStart = header.indexOf('Task');
|
|
120
138
|
|
|
@@ -122,9 +140,34 @@ describe('session-status', () => {
|
|
|
122
140
|
expect(secondRow.slice(projectStart, projectStart + 'much-longer-project'.length)).toBe('much-longer-project');
|
|
123
141
|
expect(firstRow.slice(statusStart, statusStart + 'running'.length)).toBe('running');
|
|
124
142
|
expect(secondRow.slice(statusStart, statusStart + 'running'.length)).toBe('running');
|
|
143
|
+
expect(firstRow.slice(monitorStart, monitorStart + 'idle'.length)).toBe('idle');
|
|
144
|
+
expect(secondRow.slice(monitorStart, monitorStart + 'stuck'.length)).toBe('stuck');
|
|
125
145
|
expect(firstRow.slice(elapsedStart, elapsedStart + '1h 0m'.length)).toBe('1h 0m');
|
|
126
146
|
expect(secondRow.slice(elapsedStart, elapsedStart + '30m'.length)).toBe('30m');
|
|
127
147
|
expect(firstRow.slice(taskStart, taskStart + 'Task A'.length)).toBe('Task A');
|
|
128
148
|
expect(secondRow.slice(taskStart, taskStart + 'Task B'.length)).toBe('Task B');
|
|
149
|
+
expect(secondRow.startsWith('[!] session-222')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('falls back to dashes when skill and monitor state are missing', () => {
|
|
153
|
+
const output = formatSessionStatus({
|
|
154
|
+
sessions: [
|
|
155
|
+
{
|
|
156
|
+
id: 'sess-303',
|
|
157
|
+
project: 'epsilon',
|
|
158
|
+
status: 'running',
|
|
159
|
+
created_at: '2026-03-30T11:45:00Z',
|
|
160
|
+
command: 'Inspect queue',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const header = output.split('\n').find((line) => line.startsWith('ID'));
|
|
166
|
+
const row = output.split('\n').find((line) => line.startsWith('sess-303'));
|
|
167
|
+
|
|
168
|
+
expect(header).toBeTruthy();
|
|
169
|
+
expect(row).toBeTruthy();
|
|
170
|
+
expect(row).toContain('sess-303');
|
|
171
|
+
expect(row).toContain(' - ');
|
|
129
172
|
});
|
|
130
173
|
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_ORCHESTRATOR_URL = 'http://localhost:3100';
|
|
5
|
+
const DEFAULT_ACTIVE_SESSIONS_PATH = '.tlc/.active-sessions.json';
|
|
6
|
+
const TERMINAL_STATES = new Set([
|
|
7
|
+
'completed',
|
|
8
|
+
'failed',
|
|
9
|
+
'stuck',
|
|
10
|
+
'timed_out',
|
|
11
|
+
'budget_exhausted',
|
|
12
|
+
'crashed',
|
|
13
|
+
'errored',
|
|
14
|
+
'loop_detected',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function normalizeBaseUrl(orchestratorUrl = DEFAULT_ORCHESTRATOR_URL) {
|
|
18
|
+
return String(orchestratorUrl).replace(/\/+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readActiveSessions(activeSessionsPath) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await fs.readFile(activeSessionsPath, 'utf8');
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function appendActiveSession(activeSessionsPath, session) {
|
|
32
|
+
const existing = await readActiveSessions(activeSessionsPath);
|
|
33
|
+
await fs.mkdir(path.dirname(activeSessionsPath), { recursive: true });
|
|
34
|
+
await fs.writeFile(activeSessionsPath, JSON.stringify([...existing, session], null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readJson(response) {
|
|
38
|
+
try {
|
|
39
|
+
return await response.json();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractSessionId(payload) {
|
|
46
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
47
|
+
return payload.sessionId || payload.id || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractStatusPayload(payload) {
|
|
51
|
+
if (!payload || typeof payload !== 'object') {
|
|
52
|
+
return { status: null, result: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
status: payload.status || null,
|
|
57
|
+
result: payload.result !== undefined ? payload.result : payload,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchSessionStatus(sessionId, { orchestratorUrl, fetch }) {
|
|
62
|
+
const response = await fetch(
|
|
63
|
+
`${normalizeBaseUrl(orchestratorUrl)}/sessions/${encodeURIComponent(sessionId)}/status`
|
|
64
|
+
);
|
|
65
|
+
const payload = await readJson(response);
|
|
66
|
+
return { response, payload };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check whether the background orchestrator is reachable.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {string} [options.orchestratorUrl='http://localhost:3100'] - Orchestrator base URL
|
|
74
|
+
* @param {Function} [options.fetch=globalThis.fetch] - Injectable fetch implementation
|
|
75
|
+
* @param {number} [options.timeout=2000] - Health-check timeout in milliseconds
|
|
76
|
+
* @returns {Promise<{available: true, latencyMs: number} | {available: false, reason: string}>}
|
|
77
|
+
*/
|
|
78
|
+
async function checkHealth({
|
|
79
|
+
orchestratorUrl = DEFAULT_ORCHESTRATOR_URL,
|
|
80
|
+
fetch = globalThis.fetch,
|
|
81
|
+
timeout = 2000,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(`${normalizeBaseUrl(orchestratorUrl)}/health`, {
|
|
87
|
+
signal: AbortSignal.timeout(timeout),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
return {
|
|
92
|
+
available: false,
|
|
93
|
+
reason: `health check failed with status ${response.status}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
available: true,
|
|
99
|
+
latencyMs: Date.now() - startedAt,
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
available: false,
|
|
104
|
+
reason: error && error.message ? error.message : 'health check failed',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Dispatch a background skill run to the orchestrator and persist the session ID.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} options
|
|
113
|
+
* @param {string} options.skill - Skill/task name
|
|
114
|
+
* @param {string} options.prompt - Prompt to send to the agent
|
|
115
|
+
* @param {string} options.project - Project identifier
|
|
116
|
+
* @param {string} [options.provider='codex'] - Provider command to invoke
|
|
117
|
+
* @param {string} [options.orchestratorUrl='http://localhost:3100'] - Orchestrator base URL
|
|
118
|
+
* @param {string} [options.activeSessionsPath='.tlc/.active-sessions.json'] - Active sessions file path
|
|
119
|
+
* @param {Function} [options.fetch=globalThis.fetch] - Injectable fetch implementation
|
|
120
|
+
* @returns {Promise<{sessionId: string, dispatched: true} | {dispatched: false, reason: string}>}
|
|
121
|
+
*/
|
|
122
|
+
async function dispatch({
|
|
123
|
+
skill,
|
|
124
|
+
prompt,
|
|
125
|
+
project,
|
|
126
|
+
provider = 'codex',
|
|
127
|
+
orchestratorUrl = DEFAULT_ORCHESTRATOR_URL,
|
|
128
|
+
activeSessionsPath = DEFAULT_ACTIVE_SESSIONS_PATH,
|
|
129
|
+
fetch = globalThis.fetch,
|
|
130
|
+
}) {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(`${normalizeBaseUrl(orchestratorUrl)}/sessions`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
project,
|
|
137
|
+
pool: 'local-tmux',
|
|
138
|
+
command: provider,
|
|
139
|
+
prompt,
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
return {
|
|
145
|
+
dispatched: false,
|
|
146
|
+
reason: `dispatch failed with status ${response.status}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const payload = await readJson(response);
|
|
151
|
+
const sessionId = extractSessionId(payload);
|
|
152
|
+
|
|
153
|
+
if (!sessionId) {
|
|
154
|
+
return {
|
|
155
|
+
dispatched: false,
|
|
156
|
+
reason: 'invalid session response',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await appendActiveSession(activeSessionsPath, {
|
|
161
|
+
sessionId,
|
|
162
|
+
taskName: skill,
|
|
163
|
+
startedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
sessionId,
|
|
168
|
+
dispatched: true,
|
|
169
|
+
};
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return {
|
|
172
|
+
dispatched: false,
|
|
173
|
+
reason: error && error.message ? error.message : 'dispatch failed',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Poll the orchestrator until a session reaches a terminal state.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} sessionId - Session identifier
|
|
182
|
+
* @param {object} options
|
|
183
|
+
* @param {string} [options.orchestratorUrl='http://localhost:3100'] - Orchestrator base URL
|
|
184
|
+
* @param {number} [options.interval=5000] - Poll interval in milliseconds, minimum 1000
|
|
185
|
+
* @param {number} [options.timeout=1800000] - Maximum total polling duration in milliseconds
|
|
186
|
+
* @param {Function} [options.fetch=globalThis.fetch] - Injectable fetch implementation
|
|
187
|
+
* @returns {Promise<{status: string|null, result: any}>}
|
|
188
|
+
*/
|
|
189
|
+
async function pollUntilDone(
|
|
190
|
+
sessionId,
|
|
191
|
+
{
|
|
192
|
+
orchestratorUrl = DEFAULT_ORCHESTRATOR_URL,
|
|
193
|
+
interval = 5000,
|
|
194
|
+
timeout = 1800000,
|
|
195
|
+
fetch = globalThis.fetch,
|
|
196
|
+
} = {}
|
|
197
|
+
) {
|
|
198
|
+
const pollInterval = Math.max(1000, interval);
|
|
199
|
+
const deadline = Date.now() + timeout;
|
|
200
|
+
|
|
201
|
+
while (Date.now() <= deadline) {
|
|
202
|
+
const { payload } = await fetchSessionStatus(sessionId, { orchestratorUrl, fetch });
|
|
203
|
+
const { status, result } = extractStatusPayload(payload);
|
|
204
|
+
|
|
205
|
+
if (TERMINAL_STATES.has(status)) {
|
|
206
|
+
return { status, result };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (Date.now() + pollInterval > deadline) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new Error('Poll timeout');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Capture the latest visible output and summary for a session.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} sessionId - Session identifier
|
|
223
|
+
* @param {object} options
|
|
224
|
+
* @param {string} [options.orchestratorUrl='http://localhost:3100'] - Orchestrator base URL
|
|
225
|
+
* @param {Function} [options.fetch=globalThis.fetch] - Injectable fetch implementation
|
|
226
|
+
* @returns {Promise<{output: string|null, exitCode?: number|null, summary?: string|null, error?: string}>}
|
|
227
|
+
*/
|
|
228
|
+
async function captureResult(
|
|
229
|
+
sessionId,
|
|
230
|
+
{
|
|
231
|
+
orchestratorUrl = DEFAULT_ORCHESTRATOR_URL,
|
|
232
|
+
fetch = globalThis.fetch,
|
|
233
|
+
} = {}
|
|
234
|
+
) {
|
|
235
|
+
try {
|
|
236
|
+
const { response, payload } = await fetchSessionStatus(sessionId, { orchestratorUrl, fetch });
|
|
237
|
+
|
|
238
|
+
if (response.status === 404) {
|
|
239
|
+
return { output: null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const source = payload && typeof payload.result === 'object' && payload.result !== null
|
|
243
|
+
? payload.result
|
|
244
|
+
: payload;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
output: source && Object.prototype.hasOwnProperty.call(source, 'paneSnapshot')
|
|
248
|
+
? source.paneSnapshot
|
|
249
|
+
: null,
|
|
250
|
+
exitCode: source && Object.prototype.hasOwnProperty.call(source, 'exitCode')
|
|
251
|
+
? source.exitCode
|
|
252
|
+
: null,
|
|
253
|
+
summary: source && Object.prototype.hasOwnProperty.call(source, 'summary')
|
|
254
|
+
? source.summary
|
|
255
|
+
: null,
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return {
|
|
259
|
+
output: null,
|
|
260
|
+
error: error && error.message ? error.message : 'capture failed',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
checkHealth,
|
|
267
|
+
dispatch,
|
|
268
|
+
pollUntilDone,
|
|
269
|
+
captureResult,
|
|
270
|
+
};
|