@bakapiano/ccsm 0.12.0 → 0.13.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/lib/webTerminal.js +17 -1
- package/package.json +1 -1
- package/public/css/widgets.css +32 -0
- package/public/js/api.js +12 -4
- package/public/js/components/EntityFormModal.js +37 -1
- package/public/js/components/Sidebar.js +0 -42
- package/public/js/components/TerminalView.js +16 -0
- package/public/js/pages/ConfigurePage.js +4 -4
- package/server.js +86 -1
package/lib/webTerminal.js
CHANGED
|
@@ -84,6 +84,12 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
84
84
|
if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
|
|
85
85
|
});
|
|
86
86
|
proc.onExit(({ exitCode, signal }) => {
|
|
87
|
+
// If a respawn replaced us in the pool (same entryId, new entry
|
|
88
|
+
// object), do not touch persistedSessions or schedule a delete —
|
|
89
|
+
// those belong to the new entry now. Without this guard, a slow-
|
|
90
|
+
// dying old PTY would fire markExited on the same sessionId and
|
|
91
|
+
// clobber the new spawn's markRunning state.
|
|
92
|
+
if (sessions.get(entryId) !== entry) return;
|
|
87
93
|
entry.exitCode = exitCode;
|
|
88
94
|
entry.exitedAt = Date.now();
|
|
89
95
|
const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
|
|
@@ -91,8 +97,18 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
91
97
|
try { ws.send(frame); } catch {}
|
|
92
98
|
}
|
|
93
99
|
if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
|
|
94
|
-
setTimeout(() =>
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (sessions.get(entryId) === entry) sessions.delete(entryId);
|
|
102
|
+
}, 30_000);
|
|
95
103
|
});
|
|
104
|
+
// If a previous entry exists under the same id (respawn), kill its
|
|
105
|
+
// pty so we don't have zombie claude.exe processes hanging on. The
|
|
106
|
+
// onExit guard above ensures its callback no-ops once we've taken
|
|
107
|
+
// over the slot.
|
|
108
|
+
const prev = sessions.get(entryId);
|
|
109
|
+
if (prev && !prev.exitedAt) {
|
|
110
|
+
try { prev.pty.kill(); } catch {}
|
|
111
|
+
}
|
|
96
112
|
sessions.set(entryId, entry);
|
|
97
113
|
return entry;
|
|
98
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/widgets.css
CHANGED
|
@@ -980,6 +980,38 @@
|
|
|
980
980
|
gap: 6px;
|
|
981
981
|
margin-top: 4px;
|
|
982
982
|
}
|
|
983
|
+
.entity-test-button { margin-right: auto; }
|
|
984
|
+
|
|
985
|
+
.entity-test-result {
|
|
986
|
+
margin-top: 4px;
|
|
987
|
+
padding: 8px 10px;
|
|
988
|
+
border-radius: 6px;
|
|
989
|
+
border: 1px solid var(--border);
|
|
990
|
+
background: var(--bg);
|
|
991
|
+
display: flex;
|
|
992
|
+
flex-direction: column;
|
|
993
|
+
gap: 6px;
|
|
994
|
+
font-size: 12.5px;
|
|
995
|
+
}
|
|
996
|
+
.entity-test-result.is-ok { border-color: rgba(74, 138, 74, 0.4); background: rgba(74, 138, 74, 0.06); }
|
|
997
|
+
.entity-test-result.is-fail { border-color: rgba(183, 63, 63, 0.4); background: rgba(183, 63, 63, 0.06); }
|
|
998
|
+
.entity-test-summary {
|
|
999
|
+
font-family: var(--body);
|
|
1000
|
+
font-weight: 500;
|
|
1001
|
+
color: var(--ink);
|
|
1002
|
+
}
|
|
1003
|
+
.entity-test-result.is-fail .entity-test-summary { color: var(--red); }
|
|
1004
|
+
.entity-test-out {
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
font-family: var(--mono);
|
|
1007
|
+
font-size: 11.5px;
|
|
1008
|
+
color: var(--ink-mid);
|
|
1009
|
+
white-space: pre-wrap;
|
|
1010
|
+
word-break: break-word;
|
|
1011
|
+
max-height: 120px;
|
|
1012
|
+
overflow-y: auto;
|
|
1013
|
+
}
|
|
1014
|
+
.entity-test-out.is-stderr { color: var(--ink-muted); border-top: 1px dashed var(--border); padding-top: 4px; }
|
|
983
1015
|
|
|
984
1016
|
.icon-radio {
|
|
985
1017
|
display: grid;
|
package/public/js/api.js
CHANGED
|
@@ -28,11 +28,11 @@ export async function loadConfig() {
|
|
|
28
28
|
export async function updateCli(id, patch) {
|
|
29
29
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
30
30
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
31
|
-
// Built-in CLIs lock down
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Built-in CLIs lock down structural fields (id + builtin flag) but
|
|
32
|
+
// allow command edits — users routinely need to point at an absolute
|
|
33
|
+
// path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
|
|
34
|
+
// when the bare name isn't on the spawn-time PATH.
|
|
34
35
|
if (target?.builtin) {
|
|
35
|
-
delete patch.command;
|
|
36
36
|
delete patch.id;
|
|
37
37
|
delete patch.builtin;
|
|
38
38
|
}
|
|
@@ -52,6 +52,14 @@ export async function updateCli(id, patch) {
|
|
|
52
52
|
return id;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Probe a (possibly-unsaved) CLI config: spawn its command with
|
|
56
|
+
// `--version`, capture output, see if it looks like the claimed type.
|
|
57
|
+
// `args` is intentionally ignored server-side — runtime flags can
|
|
58
|
+
// disturb a quick probe.
|
|
59
|
+
export async function testCli({ command, shell, type }) {
|
|
60
|
+
return api('POST', '/api/clis/test', { command, shell, type });
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export async function deleteCli(id) {
|
|
56
64
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
57
65
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
@@ -18,10 +18,13 @@ import { Modal } from './Modal.js';
|
|
|
18
18
|
export function EntityFormModal({
|
|
19
19
|
title, fields, initial = {}, submitLabel = 'Save',
|
|
20
20
|
readOnlyKeys = [],
|
|
21
|
-
onSubmit, onClose,
|
|
21
|
+
onSubmit, onClose, onTest, testLabel = 'Test',
|
|
22
|
+
danger,
|
|
22
23
|
}) {
|
|
23
24
|
const [draft, setDraft] = useState(() => ({ ...initialFrom(fields), ...initial }));
|
|
24
25
|
const [saving, setSaving] = useState(false);
|
|
26
|
+
const [testing, setTesting] = useState(false);
|
|
27
|
+
const [testResult, setTestResult] = useState(null);
|
|
25
28
|
|
|
26
29
|
const isReadOnly = (key) => readOnlyKeys.includes(key);
|
|
27
30
|
|
|
@@ -36,6 +39,20 @@ export function EntityFormModal({
|
|
|
36
39
|
finally { setSaving(false); }
|
|
37
40
|
};
|
|
38
41
|
|
|
42
|
+
const runTest = async () => {
|
|
43
|
+
if (!onTest) return;
|
|
44
|
+
setTesting(true);
|
|
45
|
+
setTestResult(null);
|
|
46
|
+
try {
|
|
47
|
+
const r = await onTest(draft);
|
|
48
|
+
setTestResult(r);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
setTestResult({ ok: false, spawnError: String(e?.message || e) });
|
|
51
|
+
} finally {
|
|
52
|
+
setTesting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
39
56
|
return html`
|
|
40
57
|
<${Modal} title=${title} onClose=${onClose} width=${440}>
|
|
41
58
|
<form class="entity-form" onSubmit=${submit}>
|
|
@@ -87,7 +104,26 @@ export function EntityFormModal({
|
|
|
87
104
|
${f.hint && f.type !== 'checkbox' ? html`
|
|
88
105
|
<span class="entity-field-hint">${f.hint}</span>` : null}
|
|
89
106
|
</label>`)}
|
|
107
|
+
${testResult ? html`
|
|
108
|
+
<div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
|
|
109
|
+
<div class="entity-test-summary">
|
|
110
|
+
${testResult.ok ? '✓' : '✗'} ${testResult.ok ? 'works' : 'failed'}
|
|
111
|
+
${typeof testResult.exitCode === 'number' ? html` · exit ${testResult.exitCode}` : null}
|
|
112
|
+
${typeof testResult.durationMs === 'number' ? html` · ${testResult.durationMs}ms` : null}
|
|
113
|
+
${testResult.timedOut ? html` · timed out` : null}
|
|
114
|
+
${testResult.matchedType === true ? html` · type matches ${testResult.expectedType}` : null}
|
|
115
|
+
${testResult.matchedType === false ? html` · type mismatch (expected ${testResult.expectedType})` : null}
|
|
116
|
+
</div>
|
|
117
|
+
${testResult.spawnError ? html`<pre class="entity-test-out">${testResult.spawnError}</pre>` : null}
|
|
118
|
+
${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
|
|
119
|
+
${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
|
|
120
|
+
</div>` : null}
|
|
90
121
|
<div class="entity-form-actions">
|
|
122
|
+
${onTest ? html`
|
|
123
|
+
<button type="button" class="action small subtle entity-test-button"
|
|
124
|
+
disabled=${testing} onClick=${runTest}>
|
|
125
|
+
${testing ? 'Testing…' : testLabel}
|
|
126
|
+
</button>` : null}
|
|
91
127
|
<button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
|
|
92
128
|
<button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
|
|
93
129
|
disabled=${saving}>
|
|
@@ -56,47 +56,6 @@ function SessionRow({ s }) {
|
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
const onContext = async (ev) => {
|
|
60
|
-
ev.preventDefault();
|
|
61
|
-
ev.stopPropagation();
|
|
62
|
-
// Quick menu: Rename / Move / Delete. We use sequential prompts
|
|
63
|
-
// to avoid building a real context-menu component for now.
|
|
64
|
-
const action = await ccsmPrompt(
|
|
65
|
-
`${title} · ${running ? 'running' : 'stopped'}\nType: rename / move / delete / resume / cancel`,
|
|
66
|
-
'cancel', { title: s.id, okLabel: 'OK' });
|
|
67
|
-
if (!action) return;
|
|
68
|
-
const verb = action.trim().toLowerCase();
|
|
69
|
-
if (verb === 'rename') {
|
|
70
|
-
const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
|
|
71
|
-
if (next === null) return;
|
|
72
|
-
try { await setSessionTitle(s.id, next.trim()); setToast('renamed'); }
|
|
73
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
74
|
-
} else if (verb === 'move') {
|
|
75
|
-
// Move to a folder by name
|
|
76
|
-
const folderNames = folders.value.map((f) => f.name).join(', ');
|
|
77
|
-
const target = await ccsmPrompt(
|
|
78
|
-
`Move to which folder? (empty = Unsorted)\nExisting: ${folderNames || '(none)'}`,
|
|
79
|
-
'', { title: 'Move', okLabel: 'Move' });
|
|
80
|
-
if (target === null) return;
|
|
81
|
-
const t = target.trim();
|
|
82
|
-
const folder = t ? folders.value.find((f) => f.name.toLowerCase() === t.toLowerCase()) : null;
|
|
83
|
-
if (t && !folder) { setToast(`no folder named "${t}"`, 'error'); return; }
|
|
84
|
-
try { await setSessionFolder(s.id, folder ? folder.id : null); setToast('moved'); }
|
|
85
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
86
|
-
} else if (verb === 'delete') {
|
|
87
|
-
const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
|
|
88
|
-
title: 'Delete session', okLabel: 'Delete', danger: true });
|
|
89
|
-
if (!ok) return;
|
|
90
|
-
try {
|
|
91
|
-
await deleteSession(s.id);
|
|
92
|
-
if (activeSessionId.value === s.id) activeSessionId.value = null;
|
|
93
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
94
|
-
} else if (verb === 'resume' && !running) {
|
|
95
|
-
try { await resumeSession(s.id); selectSession(s.id); }
|
|
96
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
59
|
const onRenameClick = async (ev) => {
|
|
101
60
|
ev.preventDefault();
|
|
102
61
|
ev.stopPropagation();
|
|
@@ -135,7 +94,6 @@ function SessionRow({ s }) {
|
|
|
135
94
|
onDragStart=${onDragStart}
|
|
136
95
|
onDragEnd=${onDragEnd}
|
|
137
96
|
onClick=${onClick}
|
|
138
|
-
onContextMenu=${onContext}
|
|
139
97
|
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
140
98
|
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
|
|
141
99
|
<span class="tree-label">${title}</span>
|
|
@@ -86,6 +86,22 @@ export function TerminalView({ terminalId }) {
|
|
|
86
86
|
} catch (e) {
|
|
87
87
|
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
88
|
}
|
|
89
|
+
// Ctrl+C with a selection: by default xterm.js sends \x03 AND the
|
|
90
|
+
// browser's own copy event fires — so the user gets "selection
|
|
91
|
+
// copied to clipboard" AND the running CLI gets SIGINT. Mirror
|
|
92
|
+
// VSCode/Windows Terminal behaviour: when there's a selection,
|
|
93
|
+
// suppress \x03 and let the copy event do its thing. With no
|
|
94
|
+
// selection, Ctrl+C still sends \x03 normally.
|
|
95
|
+
term.attachCustomKeyEventHandler((ev) => {
|
|
96
|
+
if (ev.type === 'keydown'
|
|
97
|
+
&& ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
|
|
98
|
+
&& ev.key.toLowerCase() === 'c'
|
|
99
|
+
&& term.hasSelection()) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
89
105
|
const host = hostRef.current;
|
|
90
106
|
term.open(host);
|
|
91
107
|
// Defer fit one tick so the container has measured layout
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../state.js';
|
|
11
11
|
import {
|
|
12
12
|
api, loadConfig, loadWorkspaces, loadFolders,
|
|
13
|
-
createCli, updateCli, deleteCli, setDefaultCli,
|
|
13
|
+
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
14
14
|
createRepo, updateRepo, deleteRepo,
|
|
15
15
|
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
16
16
|
deleteWorkspace,
|
|
@@ -252,6 +252,7 @@ export function ConfigurePage() {
|
|
|
252
252
|
${edit?.kind === 'cli-new' ? html`
|
|
253
253
|
<${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
|
|
254
254
|
onClose=${close} submitLabel="Create"
|
|
255
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
255
256
|
onSubmit=${async (v) => {
|
|
256
257
|
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
257
258
|
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
@@ -259,7 +260,7 @@ export function ConfigurePage() {
|
|
|
259
260
|
|
|
260
261
|
${edit?.kind === 'cli-edit' ? html`
|
|
261
262
|
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
|
|
262
|
-
readOnlyKeys=${edit.payload.builtin ? ['type'
|
|
263
|
+
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
263
264
|
initial=${{
|
|
264
265
|
...edit.payload,
|
|
265
266
|
args: (edit.payload.args || []).join(' '),
|
|
@@ -267,6 +268,7 @@ export function ConfigurePage() {
|
|
|
267
268
|
resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
|
|
268
269
|
}}
|
|
269
270
|
onClose=${close}
|
|
271
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
270
272
|
onSubmit=${async (v) => {
|
|
271
273
|
try {
|
|
272
274
|
const patch = {
|
|
@@ -275,8 +277,6 @@ export function ConfigurePage() {
|
|
|
275
277
|
resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
|
|
276
278
|
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
277
279
|
};
|
|
278
|
-
// command is locked on builtins — drop any tampered value.
|
|
279
|
-
if (edit.payload.builtin) delete patch.command;
|
|
280
280
|
await updateCli(edit.payload.id, patch);
|
|
281
281
|
setToast('saved');
|
|
282
282
|
} catch (e) { setToast(e.message, 'error'); throw e; }
|
package/server.js
CHANGED
|
@@ -393,7 +393,92 @@ app.put('/api/config', asyncH(async (req, res) => {
|
|
|
393
393
|
res.json(decorateConfigWithProbes(cfg));
|
|
394
394
|
}));
|
|
395
395
|
|
|
396
|
-
// ----
|
|
396
|
+
// ---- CLI probe / test ----
|
|
397
|
+
//
|
|
398
|
+
// Run the user's configured command with `--version` and report back
|
|
399
|
+
// stdout/stderr + whether the output looks like the claimed CLI type.
|
|
400
|
+
// Used by the Configure page "Test" button so the user can verify the
|
|
401
|
+
// command resolves + actually launches the right tool BEFORE saving.
|
|
402
|
+
// Body: { command, args?, shell?, type? }. args is ignored for the
|
|
403
|
+
// version probe — we always append `--version` directly so the user's
|
|
404
|
+
// runtime args (e.g. --dangerously-skip-permissions) don't perturb the
|
|
405
|
+
// quick probe.
|
|
406
|
+
app.post('/api/clis/test', asyncH(async (req, res) => {
|
|
407
|
+
const { spawn } = require('node:child_process');
|
|
408
|
+
const body = req.body || {};
|
|
409
|
+
const command = String(body.command || '').trim();
|
|
410
|
+
const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
|
|
411
|
+
const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
|
|
412
|
+
if (!command) return res.status(400).json({ error: 'command required' });
|
|
413
|
+
|
|
414
|
+
// Build the test exec. Same shell-wrapping rules as resolveCommand,
|
|
415
|
+
// but we force `--version` as the only arg and we DROP `-NoExit`
|
|
416
|
+
// from the pwsh wrapper so pwsh terminates after printing.
|
|
417
|
+
let exe, args;
|
|
418
|
+
const cmd = command.replace(/^\.[\\\/]/, '');
|
|
419
|
+
const versionArg = '--version';
|
|
420
|
+
if (shell === 'pwsh') {
|
|
421
|
+
const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
|
|
422
|
+
exe = 'pwsh.exe';
|
|
423
|
+
args = ['-NoLogo', '-Command', joined];
|
|
424
|
+
} else if (shell === 'cmd') {
|
|
425
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
426
|
+
args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
|
|
427
|
+
} else if (path.isAbsolute(cmd)) {
|
|
428
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
429
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
430
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
431
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
432
|
+
} else if (ext === '.ps1') {
|
|
433
|
+
exe = 'powershell.exe';
|
|
434
|
+
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
|
|
435
|
+
} else {
|
|
436
|
+
exe = cmd;
|
|
437
|
+
args = [versionArg];
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
441
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const t0 = Date.now();
|
|
445
|
+
let stdout = '';
|
|
446
|
+
let stderr = '';
|
|
447
|
+
let exitCode = null;
|
|
448
|
+
let timedOut = false;
|
|
449
|
+
let spawnError = null;
|
|
450
|
+
try {
|
|
451
|
+
const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
|
|
452
|
+
const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
|
|
453
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
|
|
454
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
|
|
455
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
456
|
+
child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
|
|
457
|
+
child.on('error', (err) => { clearTimeout(killer); reject(err); });
|
|
458
|
+
});
|
|
459
|
+
} catch (e) {
|
|
460
|
+
spawnError = String(e && e.message || e);
|
|
461
|
+
}
|
|
462
|
+
const durationMs = Date.now() - t0;
|
|
463
|
+
|
|
464
|
+
const out = (stdout + '\n' + stderr).toLowerCase();
|
|
465
|
+
const PATTERNS = {
|
|
466
|
+
claude: /claude/,
|
|
467
|
+
codex: /codex|openai/,
|
|
468
|
+
copilot: /copilot/,
|
|
469
|
+
};
|
|
470
|
+
const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
|
|
471
|
+
const ok = !spawnError && !timedOut && exitCode === 0;
|
|
472
|
+
res.json({
|
|
473
|
+
ok, exitCode, durationMs, timedOut, spawnError,
|
|
474
|
+
stdout: stdout.trim(),
|
|
475
|
+
stderr: stderr.trim(),
|
|
476
|
+
matchedType,
|
|
477
|
+
expectedType: type,
|
|
478
|
+
spawned: { exe, args },
|
|
479
|
+
});
|
|
480
|
+
}));
|
|
481
|
+
|
|
397
482
|
// ---- folders ----
|
|
398
483
|
|
|
399
484
|
app.get('/api/folders', asyncH(async (_req, res) => {
|