@pixelbyte-software/pixcode 1.50.2 → 1.50.4
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/dist/assets/{index-cXJ8K8Bi.js → index-DYQjBZrd.js} +164 -165
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +78 -2
- package/dist-server/server/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/pixcode-mcp-server.mjs +103 -14
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +61 -2
- package/scripts/smoke/hermes-rest-codex-launch.mjs +14 -0
- package/scripts/smoke/hermes-settings-commands.mjs +53 -2
- package/scripts/smoke/pixcode-workbench-1-48.mjs +5 -1
- package/scripts/smoke/vscode-workbench-polish.mjs +2 -2
- package/server/index.js +94 -2
|
@@ -35,7 +35,7 @@ const tools = [
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
name: 'pixcode_open_cli_terminal',
|
|
38
|
-
description: 'Use this instead of Hermes shell/proc/skill execution whenever the user asks to open Codex, Claude, Cursor, Gemini, Qwen, or OpenCode inside Pixcode. It asks the open Pixcode workbench to open a visible provider CLI terminal in the project and
|
|
38
|
+
description: 'Use this instead of Hermes shell/proc/skill execution whenever the user asks to open Codex, Claude, Cursor, Gemini, Qwen, or OpenCode inside Pixcode. It asks the open Pixcode workbench to open a visible provider CLI terminal in the project and submit startup input there. Do not run a parallel Hermes codex/claude/proc command for the same request. For multi-step, piece-by-piece, or long-running work, put the full user instruction in startupInput so the provider CLI does the work visibly inside Pixcode. When startupInput is present, Pixcode waits for the terminal to become idle before returning readback by default; never treat the first working frame as final output.',
|
|
39
39
|
inputSchema: {
|
|
40
40
|
type: 'object',
|
|
41
41
|
properties: {
|
|
@@ -66,7 +66,15 @@ const tools = [
|
|
|
66
66
|
},
|
|
67
67
|
waitForOutputMs: {
|
|
68
68
|
type: 'number',
|
|
69
|
-
description: 'Optional milliseconds to wait
|
|
69
|
+
description: 'Optional milliseconds to wait for recent terminal output. Pixcode keeps polling while terminalState is busy, so use a large value when the user asks for the final provider answer.',
|
|
70
|
+
},
|
|
71
|
+
waitForCompletionMs: {
|
|
72
|
+
type: 'number',
|
|
73
|
+
description: 'Optional explicit milliseconds to wait for the visible provider CLI to return to an idle prompt before reporting output. Overrides waitForOutputMs.',
|
|
74
|
+
},
|
|
75
|
+
launchId: {
|
|
76
|
+
type: 'number',
|
|
77
|
+
description: 'Optional Pixcode terminal launch id. Use the id returned by pixcode_open_cli_terminal when reading one specific visible terminal.',
|
|
70
78
|
},
|
|
71
79
|
},
|
|
72
80
|
required: ['provider'],
|
|
@@ -186,15 +194,87 @@ async function readProviderStatus(provider) {
|
|
|
186
194
|
return body?.data ?? body;
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
async function readProviderTerminalOutput(provider, projectPath, maxChars) {
|
|
197
|
+
async function readProviderTerminalOutput(provider, projectPath, maxChars, launchId = null) {
|
|
190
198
|
const params = new URLSearchParams({
|
|
191
199
|
provider,
|
|
192
200
|
maxChars: String(maxChars || 12000),
|
|
193
201
|
});
|
|
194
202
|
if (projectPath) params.set('projectPath', projectPath);
|
|
203
|
+
if (launchId) params.set('launchId', String(launchId));
|
|
195
204
|
return pixcodeFetch(`/api/shell/sessions/provider-output?${params.toString()}`);
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
function getLastMatchIndex(text, pattern) {
|
|
208
|
+
let lastIndex = -1;
|
|
209
|
+
for (const match of text.matchAll(pattern)) {
|
|
210
|
+
lastIndex = match.index ?? lastIndex;
|
|
211
|
+
}
|
|
212
|
+
return lastIndex;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function inferTerminalState(provider, terminalOutput) {
|
|
216
|
+
if (!terminalOutput) return 'unknown';
|
|
217
|
+
if (typeof terminalOutput.terminalState === 'string') return terminalOutput.terminalState;
|
|
218
|
+
if (typeof terminalOutput.isBusy === 'boolean') return terminalOutput.isBusy ? 'busy' : 'idle';
|
|
219
|
+
if (terminalOutput.active === false) return terminalOutput.output ? 'idle' : 'unknown';
|
|
220
|
+
|
|
221
|
+
const output = String(terminalOutput.output || '');
|
|
222
|
+
if (!output.trim()) return 'unknown';
|
|
223
|
+
if (/Process exited with code/iu.test(output)) return 'idle';
|
|
224
|
+
|
|
225
|
+
const lastBusy = Math.max(
|
|
226
|
+
getLastMatchIndex(output, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu),
|
|
227
|
+
getLastMatchIndex(output, /\bWorking\s*\([^)]*esc to interrupt[^)]*\)/giu),
|
|
228
|
+
getLastMatchIndex(output, /\bmsg=interrupt\b/giu),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (provider === 'codex') {
|
|
232
|
+
const lastPrompt = Math.max(
|
|
233
|
+
getLastMatchIndex(output, /(?:^|\n)\s*›(?:\s|$)/gu),
|
|
234
|
+
getLastMatchIndex(output, /(?:^|\n)\s*❯(?:\s|$)/gu),
|
|
235
|
+
);
|
|
236
|
+
if (lastBusy >= 0) return lastPrompt > lastBusy ? 'idle' : 'busy';
|
|
237
|
+
return lastPrompt >= 0 && /(?:Initialized|Baseline check passed|I did not modify files|Use \/skills)/iu.test(output)
|
|
238
|
+
? 'idle'
|
|
239
|
+
: 'unknown';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (lastBusy >= 0) return 'busy';
|
|
243
|
+
return 'unknown';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isTerminalReadbackFinal(provider, terminalOutput) {
|
|
247
|
+
const terminalState = inferTerminalState(provider, terminalOutput);
|
|
248
|
+
return terminalState === 'idle' || terminalState === 'completed' || terminalState === 'exited';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function waitForProviderTerminalOutput(provider, projectPath, waitMs, launchId = null) {
|
|
252
|
+
const startedAt = Date.now();
|
|
253
|
+
let latestOutput = null;
|
|
254
|
+
do {
|
|
255
|
+
const elapsed = Date.now() - startedAt;
|
|
256
|
+
const remaining = Math.max(0, waitMs - elapsed);
|
|
257
|
+
await sleep(Math.min(1000, Math.max(250, remaining)));
|
|
258
|
+
latestOutput = await readProviderTerminalOutput(provider, projectPath, 12000, launchId).catch((error) => ({
|
|
259
|
+
active: false,
|
|
260
|
+
terminalState: 'unknown',
|
|
261
|
+
error: error instanceof Error ? error.message : String(error),
|
|
262
|
+
}));
|
|
263
|
+
|
|
264
|
+
if (latestOutput?.output && isTerminalReadbackFinal(provider, latestOutput)) {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
} while (Date.now() - startedAt < waitMs);
|
|
268
|
+
|
|
269
|
+
if (latestOutput && !latestOutput.terminalState) {
|
|
270
|
+
latestOutput.terminalState = inferTerminalState(provider, latestOutput);
|
|
271
|
+
}
|
|
272
|
+
if (latestOutput && typeof latestOutput.isBusy !== 'boolean') {
|
|
273
|
+
latestOutput.isBusy = latestOutput.terminalState === 'busy';
|
|
274
|
+
}
|
|
275
|
+
return latestOutput;
|
|
276
|
+
}
|
|
277
|
+
|
|
198
278
|
function isLegacyPromptLikelyStartupInput(prompt) {
|
|
199
279
|
if (!prompt || prompt.length > 160 || prompt.includes('\n')) return false;
|
|
200
280
|
if (/^[/:!@]/u.test(prompt)) return true;
|
|
@@ -287,26 +367,27 @@ async function callTool(name, args = {}) {
|
|
|
287
367
|
permissionMode,
|
|
288
368
|
}),
|
|
289
369
|
});
|
|
370
|
+
const launchId = Number(body?.event?.id || body?.id || 0) || null;
|
|
290
371
|
let terminalOutput = null;
|
|
291
|
-
const
|
|
372
|
+
const defaultWaitMs = startupInput ? 180000 : 0;
|
|
373
|
+
const requestedWaitMs = Number(args.waitForCompletionMs ?? args.waitForOutputMs ?? defaultWaitMs);
|
|
374
|
+
const waitForOutputMs = Math.min(600000, Math.max(0, requestedWaitMs));
|
|
292
375
|
if (waitForOutputMs > 0) {
|
|
293
|
-
|
|
294
|
-
do {
|
|
295
|
-
await sleep(Math.min(1000, Math.max(250, waitForOutputMs)));
|
|
296
|
-
terminalOutput = await readProviderTerminalOutput(provider, projectPath, 12000).catch((error) => ({
|
|
297
|
-
active: false,
|
|
298
|
-
error: error instanceof Error ? error.message : String(error),
|
|
299
|
-
}));
|
|
300
|
-
if (terminalOutput?.active && terminalOutput?.output) break;
|
|
301
|
-
} while (Date.now() - startedAt < waitForOutputMs);
|
|
376
|
+
terminalOutput = await waitForProviderTerminalOutput(provider, projectPath, waitForOutputMs, launchId);
|
|
302
377
|
}
|
|
378
|
+
const terminalOutputFinal = terminalOutput ? isTerminalReadbackFinal(provider, terminalOutput) : false;
|
|
303
379
|
return textResult(JSON.stringify({
|
|
304
380
|
launched: true,
|
|
381
|
+
launchId,
|
|
305
382
|
pixcodeMcpConfigured: mcpConfigured,
|
|
306
383
|
pixcodeMcpError: mcpError,
|
|
307
384
|
event: body?.event ?? body,
|
|
308
385
|
permissionBypass: bypassPermissions,
|
|
309
386
|
status,
|
|
387
|
+
terminalOutputFinal,
|
|
388
|
+
message: terminalOutput && !terminalOutputFinal
|
|
389
|
+
? 'Provider terminal is still running or not at an idle prompt yet. Do not summarize this as final output; call pixcode_read_cli_terminal with launchId later.'
|
|
390
|
+
: undefined,
|
|
310
391
|
terminalOutput,
|
|
311
392
|
}, null, 2));
|
|
312
393
|
}
|
|
@@ -317,7 +398,15 @@ async function callTool(name, args = {}) {
|
|
|
317
398
|
? args.projectPath.trim()
|
|
318
399
|
: null;
|
|
319
400
|
const maxChars = Math.min(20000, Math.max(1000, Number(args.maxChars || 12000)));
|
|
320
|
-
const
|
|
401
|
+
const launchId = Number(args.launchId || 0) || null;
|
|
402
|
+
const body = await readProviderTerminalOutput(provider, projectPath, maxChars, launchId);
|
|
403
|
+
if (body && !body.terminalState) {
|
|
404
|
+
body.terminalState = inferTerminalState(provider, body);
|
|
405
|
+
}
|
|
406
|
+
if (body && typeof body.isBusy !== 'boolean') {
|
|
407
|
+
body.isBusy = body.terminalState === 'busy';
|
|
408
|
+
}
|
|
409
|
+
body.terminalOutputFinal = isTerminalReadbackFinal(provider, body);
|
|
321
410
|
return textResult(JSON.stringify(body, null, 2));
|
|
322
411
|
}
|
|
323
412
|
|
|
@@ -9,6 +9,7 @@ const apiKey = 'px_smoke_key';
|
|
|
9
9
|
const seen = [];
|
|
10
10
|
const providerMcpUpserts = [];
|
|
11
11
|
const terminalLaunches = [];
|
|
12
|
+
const providerOutputReads = [];
|
|
12
13
|
|
|
13
14
|
function readJson(req) {
|
|
14
15
|
return new Promise((resolve, reject) => {
|
|
@@ -90,7 +91,7 @@ const server = createServer(async (req, res) => {
|
|
|
90
91
|
res.statusCode = 201;
|
|
91
92
|
res.end(JSON.stringify({
|
|
92
93
|
event: {
|
|
93
|
-
id:
|
|
94
|
+
id: terminalLaunches.length,
|
|
94
95
|
provider: body.provider,
|
|
95
96
|
projectPath: body.projectPath,
|
|
96
97
|
prompt: body.prompt,
|
|
@@ -100,6 +101,39 @@ const server = createServer(async (req, res) => {
|
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
if (req.method === 'GET' && url.pathname === '/api/shell/sessions/provider-output') {
|
|
105
|
+
providerOutputReads.push({
|
|
106
|
+
provider: url.searchParams.get('provider'),
|
|
107
|
+
projectPath: url.searchParams.get('projectPath'),
|
|
108
|
+
launchId: url.searchParams.get('launchId'),
|
|
109
|
+
});
|
|
110
|
+
const outputs = [
|
|
111
|
+
{
|
|
112
|
+
active: true,
|
|
113
|
+
provider: 'codex',
|
|
114
|
+
projectPath: '/root/pixcode',
|
|
115
|
+
terminalState: 'busy',
|
|
116
|
+
output: 'OpenAI Codex\n› /init\n\n• Working (10s • esc to interrupt)\n',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
active: true,
|
|
120
|
+
provider: 'codex',
|
|
121
|
+
projectPath: '/root/pixcode',
|
|
122
|
+
terminalState: 'busy',
|
|
123
|
+
output: 'OpenAI Codex\n› /init\n\n• Ran npm test\n• Working (30s • esc to interrupt)\n',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
active: true,
|
|
127
|
+
provider: 'codex',
|
|
128
|
+
projectPath: '/root/pixcode',
|
|
129
|
+
terminalState: 'idle',
|
|
130
|
+
output: 'Baseline check passed: npm test reports 195 passing, 0 failing.\n\n› Use /skills to list available skills\n',
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
res.end(JSON.stringify(outputs[Math.min(providerOutputReads.length - 1, outputs.length - 1)]));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
res.statusCode = 404;
|
|
104
138
|
res.end(JSON.stringify({ error: url.pathname }));
|
|
105
139
|
});
|
|
@@ -190,13 +224,38 @@ try {
|
|
|
190
224
|
assert.equal(providerMcpUpserts.length, 1, 'Codex launch should upsert a project-scoped Pixcode MCP server');
|
|
191
225
|
assert.equal(providerMcpUpserts[0].name, 'pixcode', 'Provider MCP server should be named pixcode');
|
|
192
226
|
|
|
227
|
+
providerOutputReads.length = 0;
|
|
228
|
+
const launchWithReadback = await callMcp(child, 'tools/call', {
|
|
229
|
+
name: 'pixcode_open_cli_terminal',
|
|
230
|
+
arguments: {
|
|
231
|
+
provider: 'codex',
|
|
232
|
+
projectPath: '/root/pixcode',
|
|
233
|
+
prompt: 'read final output',
|
|
234
|
+
startupInput: '/init',
|
|
235
|
+
waitForOutputMs: 3000,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
assert(
|
|
239
|
+
providerOutputReads.length >= 3,
|
|
240
|
+
`readback should keep polling until the provider terminal is idle, reads=${providerOutputReads.length}`,
|
|
241
|
+
);
|
|
242
|
+
assert.match(
|
|
243
|
+
launchWithReadback.content[0].text,
|
|
244
|
+
/195 passing, 0 failing/,
|
|
245
|
+
'readback should return the final Codex output instead of the first working frame',
|
|
246
|
+
);
|
|
247
|
+
assert(
|
|
248
|
+
providerOutputReads.every((read) => read.launchId === '2'),
|
|
249
|
+
`readback should be tied to the Hermes terminal launch id, reads=${JSON.stringify(providerOutputReads)}`,
|
|
250
|
+
);
|
|
251
|
+
|
|
193
252
|
const blockedLaunch = await callMcp(child, 'tools/call', {
|
|
194
253
|
name: 'pixcode_open_cli_terminal',
|
|
195
254
|
arguments: { provider: 'qwen', projectPath: '/root/pixcode', prompt: 'smoke' },
|
|
196
255
|
});
|
|
197
256
|
assert.match(blockedLaunch.content[0].text, /"launched": false/, 'uninstalled providers should not create terminal launches');
|
|
198
257
|
assert.match(blockedLaunch.content[0].text, /"reason": "not_installed"/, 'uninstalled provider response should explain the block');
|
|
199
|
-
assert.equal(terminalLaunches.length,
|
|
258
|
+
assert.equal(terminalLaunches.length, 2, 'Only installed Codex provider launches should be created');
|
|
200
259
|
|
|
201
260
|
assert(seen.every((entry) => entry.auth === `Bearer ${apiKey}`), 'all MCP calls should use the Pixcode bearer key');
|
|
202
261
|
console.log('hermes MCP Pixcode roundtrip smoke passed');
|
|
@@ -84,6 +84,20 @@ const server = createServer(async (req, res) => {
|
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
if (req.method === 'GET' && url.pathname === '/api/shell/sessions/provider-output') {
|
|
88
|
+
const launchId = Number(url.searchParams.get('launchId') || 0) || null;
|
|
89
|
+
res.end(JSON.stringify({
|
|
90
|
+
active: true,
|
|
91
|
+
provider: url.searchParams.get('provider') || 'codex',
|
|
92
|
+
projectPath,
|
|
93
|
+
launchId,
|
|
94
|
+
terminalState: 'idle',
|
|
95
|
+
isBusy: false,
|
|
96
|
+
output: 'Hermes launched Codex through Pixcode MCP\n\n› ',
|
|
97
|
+
}));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
res.statusCode = 404;
|
|
88
102
|
res.end(JSON.stringify({ error: url.pathname }));
|
|
89
103
|
});
|
|
@@ -27,6 +27,7 @@ assert.match(settingsTab, /command:\s*'hermes model'/, 'Hermes settings should e
|
|
|
27
27
|
assert.match(settingsTab, /command:\s*'hermes auth'/, 'Hermes settings should expose the credential manager.');
|
|
28
28
|
assert.match(settingsTab, /command:\s*'hermes setup tools'/, 'Hermes settings should expose tool setup.');
|
|
29
29
|
assert.match(settingsTab, /command:\s*'hermes doctor'/, 'Hermes settings should expose diagnostics.');
|
|
30
|
+
assert.match(settingsTab, /command:\s*'hermes sessions browse'/, 'Hermes settings should open the interactive sessions browser, not the sessions usage screen.');
|
|
30
31
|
assert.match(
|
|
31
32
|
settingsTab,
|
|
32
33
|
/pixcode:hermes-terminal[\s\S]+command[\s\S]+title/,
|
|
@@ -82,6 +83,36 @@ assert.match(
|
|
|
82
83
|
/multi-step|piece-by-piece|long-running/i,
|
|
83
84
|
'Pixcode MCP should tell Hermes to send arbitrary multi-step work as visible provider terminal input.',
|
|
84
85
|
);
|
|
86
|
+
assert.match(
|
|
87
|
+
pixcodeMcpServer,
|
|
88
|
+
/defaultWaitMs\s*=\s*startupInput \? 180000 : 0/,
|
|
89
|
+
'Pixcode MCP should wait for visible provider completion by default when startupInput is present.',
|
|
90
|
+
);
|
|
91
|
+
assert.match(
|
|
92
|
+
pixcodeMcpServer,
|
|
93
|
+
/launchId/,
|
|
94
|
+
'Pixcode MCP should tie provider output readback to the terminal launch id.',
|
|
95
|
+
);
|
|
96
|
+
assert.match(
|
|
97
|
+
serverIndex,
|
|
98
|
+
/requestedLaunchId[\s\S]+session\.hermesLaunchId === requestedLaunchId/,
|
|
99
|
+
'Provider output API should filter by Hermes terminal launch id when supplied.',
|
|
100
|
+
);
|
|
101
|
+
assert.match(
|
|
102
|
+
serverIndex,
|
|
103
|
+
/const hermesLaunchId = Number\.isFinite\(Number\(data\.hermesLaunchId\)\)/,
|
|
104
|
+
'Shell backend should persist Hermes terminal launch ids on PTY sessions.',
|
|
105
|
+
);
|
|
106
|
+
assert.match(
|
|
107
|
+
workbench,
|
|
108
|
+
/terminalHermesLaunchId/,
|
|
109
|
+
'Workbench CLI panel should pass Hermes launch ids into provider shells.',
|
|
110
|
+
);
|
|
111
|
+
assert.match(
|
|
112
|
+
pixcodeMcpServer,
|
|
113
|
+
/terminalState is busy|terminalState.+busy|terminal to become idle/i,
|
|
114
|
+
'Pixcode MCP should not summarize the first busy terminal frame as final output.',
|
|
115
|
+
);
|
|
85
116
|
assert.match(
|
|
86
117
|
pixcodeMcpServer,
|
|
87
118
|
/startup input typed into the provider CLI/,
|
|
@@ -139,14 +170,34 @@ assert.match(
|
|
|
139
170
|
);
|
|
140
171
|
assert.match(
|
|
141
172
|
shellConnection,
|
|
142
|
-
/provider === 'codex'
|
|
143
|
-
'Codex startup input should
|
|
173
|
+
/provider === 'codex'[\s\S]+startupInputRef\.current/,
|
|
174
|
+
'Codex startup input should be handled at process launch instead of typed into an already-open TUI.',
|
|
175
|
+
);
|
|
176
|
+
assert.match(
|
|
177
|
+
shellConnection,
|
|
178
|
+
/startupInput:\s*handlesStartupInputInCommand \? startupInputForCommand : null/,
|
|
179
|
+
'Shell websocket init should send Codex startup input to the backend command builder.',
|
|
144
180
|
);
|
|
145
181
|
assert.doesNotMatch(
|
|
146
182
|
workbench,
|
|
147
183
|
/hermesCliLaunch\.startupInput \? `\$\{hermesCliLaunch\.startupInput\}\\r` : null/,
|
|
148
184
|
'Workbench should not pre-append CR before provider-aware startup input normalization.',
|
|
149
185
|
);
|
|
186
|
+
assert.match(
|
|
187
|
+
shellTypes,
|
|
188
|
+
/startupInput\?: string \| null/,
|
|
189
|
+
'Shell init messages should carry launch-time startup input for providers that accept an initial prompt.',
|
|
190
|
+
);
|
|
191
|
+
assert.match(
|
|
192
|
+
serverIndex,
|
|
193
|
+
/const startupInput = typeof data\.startupInput === 'string'/,
|
|
194
|
+
'Shell backend should read launch-time startup input from the websocket init payload.',
|
|
195
|
+
);
|
|
196
|
+
assert.match(
|
|
197
|
+
serverIndex,
|
|
198
|
+
/provider === 'codex'[\s\S]+startupInput[\s\S]+quoteShellArgForPlatform\(startupInput\)/,
|
|
199
|
+
'Codex provider terminals should start with the requested prompt as a CLI argument so banners/update notices cannot swallow Enter.',
|
|
200
|
+
);
|
|
150
201
|
assert.match(
|
|
151
202
|
shellView,
|
|
152
203
|
/permissionOverride/,
|
|
@@ -66,12 +66,15 @@ assert.match(workbench, /isBottomTerminalMinimized/, 'Bottom terminal should sup
|
|
|
66
66
|
assert.match(workbench, /bottomTerminalProject/, 'Bottom terminal should stay bound to the project it was opened for.');
|
|
67
67
|
assert.match(workbench, /setBottomTerminalProject/, 'Opening a bottom terminal should capture its project instead of following workspace selection changes.');
|
|
68
68
|
assert.match(workbench, /terminalProject = bottomTerminalProject \?\? selectedProject/, 'Workbench should render bottom terminals against their captured project binding.');
|
|
69
|
+
assert.match(workbench, /WORKBENCH_HERMES_STATE_STORAGE_KEY/, 'Hermes bottom terminal open state should be stored per workspace.');
|
|
70
|
+
assert.match(workbench, /readWorkbenchHermesState/, 'Workspace switches should restore the Hermes terminal state for that project.');
|
|
71
|
+
assert.match(workbench, /writeWorkbenchHermesState/, 'Opening, minimizing, and closing Hermes should persist per-project state.');
|
|
69
72
|
assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selected project folder without starting the selected AI CLI.');
|
|
70
73
|
assert.doesNotMatch(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should not launch from the bottom terminal through a server-side sentinel.');
|
|
71
74
|
assert.doesNotMatch(workbench, /HermesApiChatPanel|HermesTerminalTranscript/, 'Hermes Agent should use the real PTY terminal UI, not a custom REST chat transcript.');
|
|
72
75
|
assert.doesNotMatch(workbench, /REST POST \/|transport=|response=|gateway=http/, 'Hermes terminal UI must not expose REST debug internals to the user.');
|
|
73
76
|
assert.match(workbench, /HERMES_DEFAULT_COMMAND = 'hermes --yolo'/, 'Hermes Agent bottom panel should launch the actual `hermes` CLI in a bypass-enabled PTY.');
|
|
74
|
-
assert.match(workbench, /HERMES_HISTORY_COMMAND = 'hermes sessions'/, 'Hermes terminal should
|
|
77
|
+
assert.match(workbench, /HERMES_HISTORY_COMMAND = 'hermes sessions browse'/, 'Hermes terminal history should open the native interactive session picker.');
|
|
75
78
|
assert.match(workbench, /onOpenHistory=\{openHermesHistory\}/, 'Hermes terminal header should wire its history button to the native Hermes sessions command.');
|
|
76
79
|
assert.match(workbench, /Pixcode MCP Live/, 'Hermes terminal should show a user-facing Pixcode MCP live badge.');
|
|
77
80
|
assert.doesNotMatch(workbench, /ml-auto border-blue-500\/40 bg-blue-500\/10/, 'Hermes REST panel must not use right-aligned chat bubbles.');
|
|
@@ -137,6 +140,7 @@ assert.match(shellTerminal, /handleTerminalPaste/, 'Terminal should support brow
|
|
|
137
140
|
assert.match(shellTerminal, /handleCopyPasteShortcut/, 'Terminal should normalize Ctrl/Cmd copy and paste shortcuts.');
|
|
138
141
|
assert.match(shellTerminal, /event\.shiftKey/, 'Terminal should support Ctrl+Shift+C/V style shortcuts.');
|
|
139
142
|
assert.match(shellTerminal, /copyTerminalSelection/, 'Terminal should copy selected terminal text through shortcuts.');
|
|
143
|
+
assert.match(read('src/components/shell/view/Shell.tsx'), /sendInput\(`\$\{opt\.number\}\\r`\)/, 'CLI prompt option buttons should submit the selected option with Enter, not leave the choice typed.');
|
|
140
144
|
assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
|
|
141
145
|
assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
|
|
142
146
|
|
|
@@ -314,8 +314,8 @@ assert.match(
|
|
|
314
314
|
|
|
315
315
|
assert.match(
|
|
316
316
|
workbench,
|
|
317
|
-
/HERMES_HISTORY_COMMAND = 'hermes sessions'/,
|
|
318
|
-
'Hermes Agent should expose native Hermes session
|
|
317
|
+
/HERMES_HISTORY_COMMAND = 'hermes sessions browse'/,
|
|
318
|
+
'Hermes Agent should expose the native interactive Hermes session browser from the bottom terminal.',
|
|
319
319
|
);
|
|
320
320
|
|
|
321
321
|
assert.doesNotMatch(
|
package/server/index.js
CHANGED
|
@@ -324,6 +324,77 @@ function killProviderPtySessions(projectPath, provider) {
|
|
|
324
324
|
return killed;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
function getLastRegexMatchIndex(text, pattern) {
|
|
328
|
+
let lastIndex = -1;
|
|
329
|
+
for (const match of text.matchAll(pattern)) {
|
|
330
|
+
lastIndex = match.index ?? lastIndex;
|
|
331
|
+
}
|
|
332
|
+
return lastIndex;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function detectProviderTerminalState(provider, output) {
|
|
336
|
+
const cleanOutput = String(output || '');
|
|
337
|
+
if (!cleanOutput.trim()) {
|
|
338
|
+
return {
|
|
339
|
+
terminalState: 'unknown',
|
|
340
|
+
isBusy: false,
|
|
341
|
+
terminalStateReason: 'empty_output',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (/Process exited with code/iu.test(cleanOutput)) {
|
|
346
|
+
return {
|
|
347
|
+
terminalState: 'exited',
|
|
348
|
+
isBusy: false,
|
|
349
|
+
terminalStateReason: 'process_exit',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const lastBusy = Math.max(
|
|
354
|
+
getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu),
|
|
355
|
+
getLastRegexMatchIndex(cleanOutput, /\bWorking\s*\([^)]*esc to interrupt[^)]*\)/giu),
|
|
356
|
+
getLastRegexMatchIndex(cleanOutput, /\bmsg=interrupt\b/giu),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (provider === 'codex') {
|
|
360
|
+
const lastPrompt = Math.max(
|
|
361
|
+
getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*›(?:\s|$)/gu),
|
|
362
|
+
getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*❯(?:\s|$)/gu),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (lastBusy >= 0) {
|
|
366
|
+
const isBusy = lastPrompt <= lastBusy;
|
|
367
|
+
return {
|
|
368
|
+
terminalState: isBusy ? 'busy' : 'idle',
|
|
369
|
+
isBusy,
|
|
370
|
+
terminalStateReason: isBusy ? 'codex_busy_marker_after_prompt' : 'codex_prompt_after_busy_marker',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (lastPrompt >= 0 && /(?:Initialized|Baseline check passed|I did not modify files|Use \/skills)/iu.test(cleanOutput)) {
|
|
375
|
+
return {
|
|
376
|
+
terminalState: 'idle',
|
|
377
|
+
isBusy: false,
|
|
378
|
+
terminalStateReason: 'codex_idle_prompt',
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (lastBusy >= 0) {
|
|
384
|
+
return {
|
|
385
|
+
terminalState: 'busy',
|
|
386
|
+
isBusy: true,
|
|
387
|
+
terminalStateReason: 'generic_busy_marker',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
terminalState: 'unknown',
|
|
393
|
+
isBusy: false,
|
|
394
|
+
terminalStateReason: 'no_known_marker',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
327
398
|
function normalizeShellPermissionMode(value) {
|
|
328
399
|
return typeof value === 'string' ? value.trim() : '';
|
|
329
400
|
}
|
|
@@ -405,6 +476,10 @@ function quotePowerShellArg(value) {
|
|
|
405
476
|
return `"${String(value).replace(/`/g, '``').replace(/\$/g, '`$').replace(/"/g, '`"')}"`;
|
|
406
477
|
}
|
|
407
478
|
|
|
479
|
+
function quoteShellArgForPlatform(value) {
|
|
480
|
+
return os.platform() === 'win32' ? quotePowerShellArg(value) : quoteBashArg(value);
|
|
481
|
+
}
|
|
482
|
+
|
|
408
483
|
const HERMES_CLI_COMMAND_PATTERN = /^hermes(?:\s+[A-Za-z0-9._:/=@+-]+)*\s*$/;
|
|
409
484
|
|
|
410
485
|
function isHermesCliCommand(command) {
|
|
@@ -528,6 +603,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
|
|
|
528
603
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
|
|
529
604
|
? req.query.projectPath.trim()
|
|
530
605
|
: null;
|
|
606
|
+
const launchId = Number.parseInt(String(req.query.launchId || ''), 10);
|
|
607
|
+
const requestedLaunchId = Number.isFinite(launchId) && launchId > 0 ? launchId : null;
|
|
531
608
|
const maxChars = Math.min(
|
|
532
609
|
20000,
|
|
533
610
|
Math.max(1000, Number.parseInt(String(req.query.maxChars || '12000'), 10) || 12000)
|
|
@@ -543,7 +620,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
|
|
|
543
620
|
if (
|
|
544
621
|
session?.provider === provider &&
|
|
545
622
|
!session?.isPlainShell &&
|
|
546
|
-
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath)
|
|
623
|
+
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
|
|
624
|
+
(!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
|
|
547
625
|
) {
|
|
548
626
|
if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
|
|
549
627
|
matchedSession = session;
|
|
@@ -556,19 +634,24 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
|
|
|
556
634
|
active: false,
|
|
557
635
|
provider,
|
|
558
636
|
projectPath: requestedProjectPath,
|
|
637
|
+
launchId: requestedLaunchId,
|
|
559
638
|
output: '',
|
|
560
639
|
message: 'No active provider terminal session found for this project.',
|
|
561
640
|
});
|
|
562
641
|
}
|
|
563
642
|
|
|
564
643
|
const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
|
|
644
|
+
const output = stripAnsiSequences(rawOutput);
|
|
645
|
+
const terminalState = detectProviderTerminalState(provider, output);
|
|
565
646
|
res.json({
|
|
566
647
|
active: true,
|
|
567
648
|
provider,
|
|
568
649
|
projectPath: path.resolve(matchedSession.projectPath || os.homedir()),
|
|
569
650
|
sessionId: matchedSession.sessionId || null,
|
|
651
|
+
launchId: matchedSession.hermesLaunchId || null,
|
|
570
652
|
updatedAt: matchedSession.updatedAt || null,
|
|
571
|
-
|
|
653
|
+
...terminalState,
|
|
654
|
+
output,
|
|
572
655
|
});
|
|
573
656
|
});
|
|
574
657
|
|
|
@@ -2261,6 +2344,12 @@ function handleShellConnection(ws, request) {
|
|
|
2261
2344
|
const hasSession = data.hasSession;
|
|
2262
2345
|
const provider = data.provider || 'claude';
|
|
2263
2346
|
const initialCommand = data.initialCommand;
|
|
2347
|
+
const startupInput = typeof data.startupInput === 'string' && data.startupInput.trim()
|
|
2348
|
+
? data.startupInput.trim()
|
|
2349
|
+
: null;
|
|
2350
|
+
const hermesLaunchId = Number.isFinite(Number(data.hermesLaunchId)) && Number(data.hermesLaunchId) > 0
|
|
2351
|
+
? Number(data.hermesLaunchId)
|
|
2352
|
+
: null;
|
|
2264
2353
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
2265
2354
|
const isHermesCliLaunch = isPlainShell && isHermesCliCommand(initialCommand);
|
|
2266
2355
|
const forceNewSession = Boolean(data.forceNewSession);
|
|
@@ -2418,6 +2507,8 @@ function handleShellConnection(ws, request) {
|
|
|
2418
2507
|
} else {
|
|
2419
2508
|
shellCommand = `${command} resume "${sessionId}" || ${command}`;
|
|
2420
2509
|
}
|
|
2510
|
+
} else if (startupInput) {
|
|
2511
|
+
shellCommand = `${command} ${quoteShellArgForPlatform(startupInput)}`;
|
|
2421
2512
|
} else {
|
|
2422
2513
|
shellCommand = command;
|
|
2423
2514
|
}
|
|
@@ -2539,6 +2630,7 @@ function handleShellConnection(ws, request) {
|
|
|
2539
2630
|
timeoutId: null,
|
|
2540
2631
|
projectPath,
|
|
2541
2632
|
sessionId,
|
|
2633
|
+
hermesLaunchId,
|
|
2542
2634
|
provider,
|
|
2543
2635
|
isPlainShell,
|
|
2544
2636
|
keepAliveUntilExit: false,
|