@profoundlogic/coderflow-cli 0.2.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/LICENSE.txt +322 -0
- package/README.md +102 -0
- package/coder.js +202 -0
- package/lib/commands/apply.js +238 -0
- package/lib/commands/attach.js +143 -0
- package/lib/commands/config.js +226 -0
- package/lib/commands/containers.js +213 -0
- package/lib/commands/discard.js +167 -0
- package/lib/commands/interactive.js +292 -0
- package/lib/commands/jira.js +464 -0
- package/lib/commands/license.js +172 -0
- package/lib/commands/list.js +104 -0
- package/lib/commands/login.js +329 -0
- package/lib/commands/logs.js +66 -0
- package/lib/commands/profile.js +539 -0
- package/lib/commands/reject.js +53 -0
- package/lib/commands/results.js +89 -0
- package/lib/commands/run.js +237 -0
- package/lib/commands/server.js +537 -0
- package/lib/commands/status.js +39 -0
- package/lib/commands/test.js +335 -0
- package/lib/config.js +378 -0
- package/lib/help.js +444 -0
- package/lib/http-client.js +180 -0
- package/lib/oidc.js +126 -0
- package/lib/profile.js +296 -0
- package/lib/state-capture.js +336 -0
- package/lib/task-grouping.js +210 -0
- package/lib/terminal-client.js +162 -0
- package/package.json +35 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Repository State Capture (Client-side)
|
|
3
|
+
* Captures complete git state including local branches, staged/unstaged changes, untracked files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { promises as fs } from 'fs';
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Execute git command in a repository
|
|
15
|
+
*/
|
|
16
|
+
async function execGit(repoPath, command) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout, stderr } = await execAsync(`git ${command}`, {
|
|
19
|
+
cwd: repoPath,
|
|
20
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB for large diffs
|
|
21
|
+
encoding: 'utf8'
|
|
22
|
+
});
|
|
23
|
+
return stdout.trim();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a directory is a git repository
|
|
31
|
+
*/
|
|
32
|
+
async function isGitRepo(dirPath) {
|
|
33
|
+
try {
|
|
34
|
+
const gitDir = await execGit(dirPath, 'rev-parse --git-dir');
|
|
35
|
+
return gitDir !== null;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect all git repositories in a directory tree
|
|
43
|
+
* @param {string} basePath - Directory to search
|
|
44
|
+
* @param {Array} repoConfigs - Repository configurations from environment.json with name, path, etc.
|
|
45
|
+
* @returns {Object} Map of repoName -> absolutePath
|
|
46
|
+
*/
|
|
47
|
+
async function discoverRepos(basePath, repoConfigs) {
|
|
48
|
+
const discovered = {};
|
|
49
|
+
|
|
50
|
+
// Strategy 1: Use configured paths from environment.json
|
|
51
|
+
for (const repoConfig of repoConfigs) {
|
|
52
|
+
if (discovered[repoConfig.name]) continue;
|
|
53
|
+
|
|
54
|
+
// Use the path from config, or fall back to name
|
|
55
|
+
const repoPath = repoConfig.path || repoConfig.name;
|
|
56
|
+
|
|
57
|
+
// Try the path relative to basePath
|
|
58
|
+
const fullPath = path.join(basePath, repoPath);
|
|
59
|
+
try {
|
|
60
|
+
if (await isGitRepo(fullPath)) {
|
|
61
|
+
discovered[repoConfig.name] = fullPath;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Path doesn't exist
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Try parent directory + configured path
|
|
69
|
+
const parentPath = path.resolve(basePath, '..');
|
|
70
|
+
const parentFullPath = path.join(parentPath, repoPath);
|
|
71
|
+
try {
|
|
72
|
+
if (await isGitRepo(parentFullPath)) {
|
|
73
|
+
discovered[repoConfig.name] = parentFullPath;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Path doesn't exist
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Strategy 2: Check if basePath itself is one of the repos
|
|
82
|
+
const baseRepoName = path.basename(basePath);
|
|
83
|
+
const baseRepo = repoConfigs.find(r => r.name === baseRepoName);
|
|
84
|
+
if (baseRepo && !discovered[baseRepoName] && await isGitRepo(basePath)) {
|
|
85
|
+
discovered[baseRepoName] = basePath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Strategy 3: Search nested directories as fallback (for repos not yet configured with paths)
|
|
89
|
+
async function searchNested(dir, depth = 0) {
|
|
90
|
+
if (depth > 2) return; // Don't search too deep
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fullPath = path.join(dir, entry.name);
|
|
101
|
+
|
|
102
|
+
// Check if this directory matches any repo name
|
|
103
|
+
const matchingRepo = repoConfigs.find(r => r.name === entry.name);
|
|
104
|
+
if (matchingRepo && !discovered[matchingRepo.name]) {
|
|
105
|
+
if (await isGitRepo(fullPath)) {
|
|
106
|
+
discovered[matchingRepo.name] = fullPath;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recurse into subdirectories
|
|
111
|
+
await searchNested(fullPath, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Skip directories we can't read
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await searchNested(basePath);
|
|
119
|
+
|
|
120
|
+
return discovered;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get contents of untracked files
|
|
125
|
+
*/
|
|
126
|
+
async function getUntrackedFiles(repoPath) {
|
|
127
|
+
const untrackedList = await execGit(repoPath, 'ls-files --others --exclude-standard');
|
|
128
|
+
if (!untrackedList) return {};
|
|
129
|
+
|
|
130
|
+
const files = {};
|
|
131
|
+
const fileNames = untrackedList.split('\n').filter(f => f.trim());
|
|
132
|
+
|
|
133
|
+
for (const fileName of fileNames) {
|
|
134
|
+
const filePath = path.join(repoPath, fileName);
|
|
135
|
+
try {
|
|
136
|
+
// Check file size first (skip large files)
|
|
137
|
+
const stats = await fs.stat(filePath);
|
|
138
|
+
if (stats.size > 1024 * 1024) { // Skip files > 1MB
|
|
139
|
+
console.warn(`Skipping large untracked file: ${fileName} (${stats.size} bytes)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
144
|
+
files[fileName] = content;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.warn(`Could not read untracked file ${fileName}: ${error.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Capture complete state of a single repository
|
|
155
|
+
*/
|
|
156
|
+
async function captureRepoState(repoPath, repoName) {
|
|
157
|
+
const currentBranch = await execGit(repoPath, 'rev-parse --abbrev-ref HEAD');
|
|
158
|
+
if (!currentBranch) {
|
|
159
|
+
throw new Error(`Could not determine current branch for ${repoName}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if branch exists on remote
|
|
163
|
+
const remoteBranch = await execGit(repoPath, 'rev-parse --abbrev-ref @{u}');
|
|
164
|
+
const isRemoteTracking = remoteBranch !== null;
|
|
165
|
+
|
|
166
|
+
const state = {
|
|
167
|
+
repo_name: repoName,
|
|
168
|
+
repo_path: repoPath,
|
|
169
|
+
current_branch: currentBranch,
|
|
170
|
+
is_remote_tracking: isRemoteTracking,
|
|
171
|
+
remote_branch: remoteBranch,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Capture local commits (both local-only branches and unpushed commits on remote-tracking branches)
|
|
175
|
+
let baseBranch = null;
|
|
176
|
+
let commitsAhead = 0;
|
|
177
|
+
|
|
178
|
+
if (isRemoteTracking) {
|
|
179
|
+
// Remote-tracking branch - check for unpushed commits
|
|
180
|
+
const remoteBranchRef = `origin/${currentBranch}`;
|
|
181
|
+
const remoteExists = await execGit(repoPath, `rev-parse --verify ${remoteBranchRef} 2>/dev/null`);
|
|
182
|
+
|
|
183
|
+
if (remoteExists) {
|
|
184
|
+
baseBranch = remoteBranchRef;
|
|
185
|
+
const ahead = await execGit(repoPath, `rev-list --count ${remoteBranchRef}..HEAD`);
|
|
186
|
+
commitsAhead = parseInt(ahead) || 0;
|
|
187
|
+
}
|
|
188
|
+
} else if (currentBranch !== 'HEAD') {
|
|
189
|
+
// Local-only branch - find base branch point
|
|
190
|
+
for (const candidate of ['dev', 'main', 'master']) {
|
|
191
|
+
const exists = await execGit(repoPath, `rev-parse --verify ${candidate} 2>/dev/null`);
|
|
192
|
+
if (exists) {
|
|
193
|
+
baseBranch = candidate;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (baseBranch) {
|
|
199
|
+
const ahead = await execGit(repoPath, `rev-list --count ${baseBranch}..HEAD`);
|
|
200
|
+
commitsAhead = parseInt(ahead) || 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Capture commit patches if we have commits ahead
|
|
205
|
+
if (baseBranch && commitsAhead > 0) {
|
|
206
|
+
state.base_branch = baseBranch;
|
|
207
|
+
state.commits_ahead = commitsAhead;
|
|
208
|
+
|
|
209
|
+
const commitPatch = await execGit(repoPath, `format-patch ${baseBranch}..HEAD --stdout`);
|
|
210
|
+
if (commitPatch) {
|
|
211
|
+
state.commit_patch = commitPatch;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Capture uncommitted changes (unstaged working directory changes)
|
|
216
|
+
const unstaged = await execGit(repoPath, 'diff HEAD');
|
|
217
|
+
if (unstaged) {
|
|
218
|
+
state.unstaged = unstaged;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Capture staged changes
|
|
222
|
+
const staged = await execGit(repoPath, 'diff --cached');
|
|
223
|
+
if (staged) {
|
|
224
|
+
state.staged = staged;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Capture untracked files
|
|
228
|
+
const untracked = await getUntrackedFiles(repoPath);
|
|
229
|
+
const untrackedCount = Object.keys(untracked).length;
|
|
230
|
+
if (untrackedCount > 0) {
|
|
231
|
+
state.untracked_files = untracked;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Get current commit hash for reference
|
|
235
|
+
state.current_commit = await execGit(repoPath, 'rev-parse HEAD');
|
|
236
|
+
|
|
237
|
+
// Get remote URL for verification
|
|
238
|
+
state.remote_url = await execGit(repoPath, 'config --get remote.origin.url');
|
|
239
|
+
|
|
240
|
+
return state;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Main function: Capture state of all repositories
|
|
245
|
+
*
|
|
246
|
+
* @param {string} sourcePath - Directory to search for repos (typically cwd)
|
|
247
|
+
* @param {Array} repoConfigs - Repository configurations from environment.json (objects with name, path, etc.)
|
|
248
|
+
* @returns {Promise<Object>} Complete state object
|
|
249
|
+
*/
|
|
250
|
+
export async function captureAllRepos(sourcePath, repoConfigs) {
|
|
251
|
+
const repoNames = repoConfigs.map(r => r.name);
|
|
252
|
+
|
|
253
|
+
// Discover repositories
|
|
254
|
+
const discovered = await discoverRepos(sourcePath, repoConfigs);
|
|
255
|
+
|
|
256
|
+
const foundCount = Object.keys(discovered).length;
|
|
257
|
+
|
|
258
|
+
if (foundCount === 0) {
|
|
259
|
+
const error = `No repositories found!
|
|
260
|
+
|
|
261
|
+
Expected repos: ${repoNames.join(', ')}
|
|
262
|
+
Search path: ${sourcePath}
|
|
263
|
+
|
|
264
|
+
Please run this command from:
|
|
265
|
+
- The parent directory containing all repos, or
|
|
266
|
+
- Inside one of the repository directories
|
|
267
|
+
|
|
268
|
+
The system will automatically discover repos in the current directory,
|
|
269
|
+
subdirectories, and parent directory.`;
|
|
270
|
+
|
|
271
|
+
throw new Error(error);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Warn about missing repos
|
|
275
|
+
const missing = repoNames.filter(r => !discovered[r]);
|
|
276
|
+
if (missing.length > 0) {
|
|
277
|
+
console.warn(`Warning: Missing repositories: ${missing.join(', ')} - will use container defaults`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Capture state for each discovered repo
|
|
281
|
+
const states = {};
|
|
282
|
+
for (const [repoName, repoPath] of Object.entries(discovered)) {
|
|
283
|
+
try {
|
|
284
|
+
states[repoName] = await captureRepoState(repoPath, repoName);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new Error(`Failed to capture state for ${repoName}: ${error.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
captured_at: new Date().toISOString(),
|
|
292
|
+
source_path: sourcePath,
|
|
293
|
+
repositories: states,
|
|
294
|
+
repos_found: Object.keys(states),
|
|
295
|
+
repos_missing: missing
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Validate that captured state can be applied
|
|
301
|
+
* Returns validation result with any errors
|
|
302
|
+
*/
|
|
303
|
+
export function validateState(stateObj) {
|
|
304
|
+
const errors = [];
|
|
305
|
+
const warnings = [];
|
|
306
|
+
|
|
307
|
+
if (!stateObj.repositories || Object.keys(stateObj.repositories).length === 0) {
|
|
308
|
+
errors.push('No repositories in state object');
|
|
309
|
+
return { valid: false, errors, warnings };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const [repoName, state] of Object.entries(stateObj.repositories)) {
|
|
313
|
+
if (!state.current_branch) {
|
|
314
|
+
errors.push(`${repoName}: Missing current_branch`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Warn about very large patches (but don't fail)
|
|
318
|
+
if (state.commit_patch && state.commit_patch.length > 10 * 1024 * 1024) {
|
|
319
|
+
warnings.push(`${repoName}: Commit patch is very large (${(state.commit_patch.length / 1024 / 1024).toFixed(1)}MB)`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (state.unstaged && state.unstaged.length > 10 * 1024 * 1024) {
|
|
323
|
+
warnings.push(`${repoName}: Unstaged diff is very large (${(state.unstaged.length / 1024 / 1024).toFixed(1)}MB)`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (state.untracked_files && Object.keys(state.untracked_files).length > 100) {
|
|
327
|
+
warnings.push(`${repoName}: Many untracked files (${Object.keys(state.untracked_files).length})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
valid: errors.length === 0,
|
|
333
|
+
errors,
|
|
334
|
+
warnings
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task grouping utilities - mirrors the web UI grouping logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compare parameters excluding 'instructions' field
|
|
7
|
+
*/
|
|
8
|
+
function areParametersEqual(params1, params2) {
|
|
9
|
+
const keys1 = Object.keys(params1 || {}).filter(k => k !== 'instructions').sort();
|
|
10
|
+
const keys2 = Object.keys(params2 || {}).filter(k => k !== 'instructions').sort();
|
|
11
|
+
|
|
12
|
+
if (keys1.length !== keys2.length) return false;
|
|
13
|
+
|
|
14
|
+
return keys1.every(key => params1[key] === params2[key]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Group related tasks as variants
|
|
19
|
+
* Criteria:
|
|
20
|
+
* - Same environment
|
|
21
|
+
* - Same taskType
|
|
22
|
+
* - Same testName (if task_type is 'test')
|
|
23
|
+
* - Same parameters (excluding instructions)
|
|
24
|
+
* - Created within 25 seconds of each other
|
|
25
|
+
*/
|
|
26
|
+
export function groupRelatedTasks(tasks) {
|
|
27
|
+
const groups = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
|
|
30
|
+
for (const task of tasks) {
|
|
31
|
+
if (seen.has(task.taskId)) continue;
|
|
32
|
+
|
|
33
|
+
// Find all tasks submitted ~same time with same params
|
|
34
|
+
const related = tasks.filter(t =>
|
|
35
|
+
!seen.has(t.taskId) &&
|
|
36
|
+
t.environment === task.environment &&
|
|
37
|
+
t.taskType === task.taskType &&
|
|
38
|
+
(t.taskType !== 'test' || t.testName === task.testName) &&
|
|
39
|
+
areParametersEqual(t.parameters, task.parameters) &&
|
|
40
|
+
Math.abs(new Date(t.createdAt) - new Date(task.createdAt)) < 25000
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (related.length > 1) {
|
|
44
|
+
// Sort variants by agent (claude first, then codex) and creation time
|
|
45
|
+
const sorted = related.sort((a, b) => {
|
|
46
|
+
const agentA = a.envVars?.CODER_AGENT || a.envVars?.default_agent || '';
|
|
47
|
+
const agentB = b.envVars?.CODER_AGENT || b.envVars?.default_agent || '';
|
|
48
|
+
if (agentA !== agentB) {
|
|
49
|
+
// Sort claude before codex
|
|
50
|
+
if (agentA === 'claude') return -1;
|
|
51
|
+
if (agentB === 'claude') return 1;
|
|
52
|
+
}
|
|
53
|
+
// Same agent, sort by creation time
|
|
54
|
+
const timeA = new Date(a.createdAt).getTime();
|
|
55
|
+
const timeB = new Date(b.createdAt).getTime();
|
|
56
|
+
return timeA - timeB;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
groups.push({
|
|
60
|
+
primary: sorted[0],
|
|
61
|
+
variants: sorted,
|
|
62
|
+
isGroup: true
|
|
63
|
+
});
|
|
64
|
+
related.forEach(t => seen.add(t.taskId));
|
|
65
|
+
} else {
|
|
66
|
+
groups.push({
|
|
67
|
+
primary: task,
|
|
68
|
+
variants: [task],
|
|
69
|
+
isGroup: false
|
|
70
|
+
});
|
|
71
|
+
seen.add(task.taskId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return groups;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get a brief description of what the task did
|
|
80
|
+
*/
|
|
81
|
+
function getTaskDescription(task) {
|
|
82
|
+
// Try summary first (if included from server)
|
|
83
|
+
if (task.summary) {
|
|
84
|
+
// Extract first line or first 80 chars
|
|
85
|
+
const firstLine = task.summary.split('\n')[0].trim();
|
|
86
|
+
|
|
87
|
+
// Skip generic completion messages
|
|
88
|
+
if (firstLine.includes('completed successfully') ||
|
|
89
|
+
firstLine.includes('Task completed') ||
|
|
90
|
+
firstLine.match(/^Task \d+-\w+ completed/)) {
|
|
91
|
+
// Skip this line, try second line if available
|
|
92
|
+
const lines = task.summary.split('\n');
|
|
93
|
+
if (lines.length > 1) {
|
|
94
|
+
const secondLine = lines[1].trim();
|
|
95
|
+
if (secondLine && secondLine.length > 0) {
|
|
96
|
+
if (secondLine.length > 80) {
|
|
97
|
+
return secondLine.substring(0, 77) + '...';
|
|
98
|
+
}
|
|
99
|
+
return secondLine;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Fall through to try instructions
|
|
103
|
+
} else {
|
|
104
|
+
if (firstLine.length > 80) {
|
|
105
|
+
return firstLine.substring(0, 77) + '...';
|
|
106
|
+
}
|
|
107
|
+
return firstLine;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Try parameters.instructions
|
|
112
|
+
if (task.parameters?.instructions) {
|
|
113
|
+
const firstLine = task.parameters.instructions.split('\n')[0].trim();
|
|
114
|
+
if (firstLine.length > 80) {
|
|
115
|
+
return firstLine.substring(0, 77) + '...';
|
|
116
|
+
}
|
|
117
|
+
return firstLine;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Try test name
|
|
121
|
+
if (task.taskType === 'test' && task.testName) {
|
|
122
|
+
return `Test: ${task.testName}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try task type
|
|
126
|
+
if (task.taskType && task.taskType !== 'manual') {
|
|
127
|
+
return task.taskType;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fallback
|
|
131
|
+
return 'No description';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format a relative time string
|
|
136
|
+
*/
|
|
137
|
+
function formatRelativeTime(date) {
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const diffMs = now - date;
|
|
140
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
141
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
142
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
143
|
+
|
|
144
|
+
if (diffMins < 1) return 'just now';
|
|
145
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
146
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
147
|
+
if (diffDays === 1) return 'yesterday';
|
|
148
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
149
|
+
|
|
150
|
+
// For older tasks, show date
|
|
151
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
152
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
153
|
+
return `${month}/${day}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Format a task for display in selection list
|
|
158
|
+
* @param {Object} task - The task to format
|
|
159
|
+
* @param {boolean} isVariant - Whether this is a variant display (for sub-menu)
|
|
160
|
+
* @param {boolean} showAgent - Whether to show the agent badge (default: true)
|
|
161
|
+
*/
|
|
162
|
+
export function formatTaskForDisplay(task, isVariant = false, showAgent = true) {
|
|
163
|
+
const agent = task.envVars?.CODER_AGENT || task.envVars?.default_agent || 'unknown';
|
|
164
|
+
const date = new Date(task.createdAt);
|
|
165
|
+
const timeStr = formatRelativeTime(date);
|
|
166
|
+
|
|
167
|
+
// Agent badge with color indicator
|
|
168
|
+
const agentBadge = agent === 'claude' ? 'Claude' :
|
|
169
|
+
agent === 'codex' ? 'Codex' :
|
|
170
|
+
agent;
|
|
171
|
+
|
|
172
|
+
let label = '';
|
|
173
|
+
|
|
174
|
+
if (isVariant) {
|
|
175
|
+
// Variant display (for sub-menu) - show task ID, agent, and time
|
|
176
|
+
label = `${task.taskId} • ${agentBadge} • ${timeStr}`;
|
|
177
|
+
} else {
|
|
178
|
+
// Primary task display - show description, environment, time, and optionally agent
|
|
179
|
+
const description = getTaskDescription(task);
|
|
180
|
+
const env = task.environment || 'default';
|
|
181
|
+
|
|
182
|
+
if (showAgent) {
|
|
183
|
+
label = `${description} • ${agentBadge} • ${env} • ${timeStr}`;
|
|
184
|
+
} else {
|
|
185
|
+
label = `${description} • ${env} • ${timeStr}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return label;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Format a task group for display in selection list
|
|
194
|
+
*/
|
|
195
|
+
export function formatGroupForDisplay(group) {
|
|
196
|
+
const task = group.primary;
|
|
197
|
+
const variantCount = group.variants.length;
|
|
198
|
+
|
|
199
|
+
// For multi-agent groups, don't show the agent badge since it would be misleading
|
|
200
|
+
// (only shows one agent when there are multiple)
|
|
201
|
+
const showAgent = !group.isGroup || variantCount === 1;
|
|
202
|
+
let label = formatTaskForDisplay(task, false, showAgent);
|
|
203
|
+
|
|
204
|
+
// Add variant count if this is a group with multiple variants
|
|
205
|
+
if (group.isGroup && variantCount > 1) {
|
|
206
|
+
label += ` (${variantCount} agents)`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return label;
|
|
210
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket terminal client
|
|
3
|
+
* Connects to server's terminal WebSocket and provides interactive terminal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WebSocket } from 'ws';
|
|
7
|
+
import { getServerUrl } from './config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Connect to server's WebSocket terminal and provide interactive session
|
|
11
|
+
* @param {string} containerId - Container ID
|
|
12
|
+
* @param {string} cmd - Command to execute (optional)
|
|
13
|
+
* @returns {Promise<number>} Exit code
|
|
14
|
+
*/
|
|
15
|
+
export async function connectTerminal(containerId, cmd = null) {
|
|
16
|
+
const serverUrl = await getServerUrl();
|
|
17
|
+
|
|
18
|
+
// Convert HTTP URL to WebSocket URL
|
|
19
|
+
const wsUrl = new URL(serverUrl);
|
|
20
|
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
21
|
+
wsUrl.pathname = `/ws/containers/${encodeURIComponent(containerId)}`;
|
|
22
|
+
if (cmd) {
|
|
23
|
+
wsUrl.searchParams.set('cmd', cmd);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const ws = new WebSocket(wsUrl.toString());
|
|
28
|
+
let connected = false;
|
|
29
|
+
|
|
30
|
+
ws.on('open', () => {
|
|
31
|
+
connected = true;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
ws.on('message', (data) => {
|
|
35
|
+
try {
|
|
36
|
+
const message = JSON.parse(data.toString());
|
|
37
|
+
|
|
38
|
+
if (message.type === 'status') {
|
|
39
|
+
if (message.status === 'connected') {
|
|
40
|
+
// Terminal connected - set up stdin forwarding
|
|
41
|
+
setupStdinForwarding(ws);
|
|
42
|
+
} else if (message.status === 'terminated') {
|
|
43
|
+
// Terminal session ended normally
|
|
44
|
+
cleanupStdin();
|
|
45
|
+
ws.close();
|
|
46
|
+
resolve(0);
|
|
47
|
+
}
|
|
48
|
+
} else if (message.type === 'data') {
|
|
49
|
+
// Output from terminal
|
|
50
|
+
process.stdout.write(message.data);
|
|
51
|
+
} else if (message.type === 'error') {
|
|
52
|
+
// Error from server
|
|
53
|
+
console.error(`\nTerminal error: ${message.message}`);
|
|
54
|
+
cleanupStdin();
|
|
55
|
+
ws.close();
|
|
56
|
+
resolve(1);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// Ignore JSON parse errors
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
ws.on('error', (error) => {
|
|
64
|
+
if (!connected) {
|
|
65
|
+
console.error(`\nFailed to connect to server terminal: ${error.message}`);
|
|
66
|
+
console.error('\nPossible solutions:');
|
|
67
|
+
console.error(' 1. Check that the server is running');
|
|
68
|
+
console.error(' 2. Verify CODER_SERVER_URL is correct');
|
|
69
|
+
console.error(' 3. Ensure the container still exists');
|
|
70
|
+
} else {
|
|
71
|
+
console.error(`\nWebSocket error: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
cleanupStdin();
|
|
74
|
+
reject(error);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
ws.on('close', (code, reason) => {
|
|
78
|
+
cleanupStdin();
|
|
79
|
+
if (!connected) {
|
|
80
|
+
resolve(1);
|
|
81
|
+
} else if (code !== 1000) {
|
|
82
|
+
// Non-normal closure
|
|
83
|
+
console.error(`\nConnection closed: ${reason || 'Unknown reason'}`);
|
|
84
|
+
resolve(1);
|
|
85
|
+
}
|
|
86
|
+
// Normal closure already handled by 'terminated' status
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Handle Ctrl+C gracefully
|
|
90
|
+
process.on('SIGINT', () => {
|
|
91
|
+
cleanupStdin();
|
|
92
|
+
ws.close();
|
|
93
|
+
resolve(130); // Standard exit code for SIGINT
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Set up stdin forwarding to WebSocket
|
|
100
|
+
*/
|
|
101
|
+
function setupStdinForwarding(ws) {
|
|
102
|
+
// Set stdin to raw mode for interactive terminal
|
|
103
|
+
if (process.stdin.isTTY) {
|
|
104
|
+
process.stdin.setRawMode(true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stdin.resume();
|
|
108
|
+
process.stdin.setEncoding('utf8');
|
|
109
|
+
|
|
110
|
+
// Forward stdin to WebSocket
|
|
111
|
+
const stdinHandler = (data) => {
|
|
112
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
113
|
+
ws.send(JSON.stringify({ type: 'data', data }));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
process.stdin.on('data', stdinHandler);
|
|
118
|
+
|
|
119
|
+
// Store handler for cleanup
|
|
120
|
+
process.stdin._terminalHandler = stdinHandler;
|
|
121
|
+
|
|
122
|
+
// Handle terminal resize events
|
|
123
|
+
if (process.stdout.isTTY) {
|
|
124
|
+
const sendResize = () => {
|
|
125
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
126
|
+
ws.send(JSON.stringify({
|
|
127
|
+
type: 'resize',
|
|
128
|
+
cols: process.stdout.columns,
|
|
129
|
+
rows: process.stdout.rows
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Send initial size
|
|
135
|
+
sendResize();
|
|
136
|
+
|
|
137
|
+
// Send on resize
|
|
138
|
+
process.stdout.on('resize', sendResize);
|
|
139
|
+
process.stdout._resizeHandler = sendResize;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Clean up stdin handlers and restore terminal state
|
|
145
|
+
*/
|
|
146
|
+
function cleanupStdin() {
|
|
147
|
+
if (process.stdin._terminalHandler) {
|
|
148
|
+
process.stdin.removeListener('data', process.stdin._terminalHandler);
|
|
149
|
+
delete process.stdin._terminalHandler;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.stdout._resizeHandler) {
|
|
153
|
+
process.stdout.removeListener('resize', process.stdout._resizeHandler);
|
|
154
|
+
delete process.stdout._resizeHandler;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (process.stdin.isTTY) {
|
|
158
|
+
process.stdin.setRawMode(false);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
process.stdin.pause();
|
|
162
|
+
}
|