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.
@@ -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.id || '-',
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.id || '-',
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.startsWith('session-222'));
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
+ };