@pixelbyte-software/pixcode 1.50.4 → 1.50.6
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-DYQjBZrd.js → index-DVEXTVKy.js} +3 -3
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +106 -35
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +2 -0
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/routes/network.js +2 -2
- package/dist-server/server/routes/network.js.map +1 -1
- package/dist-server/server/services/external-access.js +193 -11
- package/dist-server/server/services/external-access.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/configure-pixcode-mcp.mjs +8 -5
- package/scripts/hermes/pixcode-mcp-server.mjs +85 -14
- package/scripts/smoke/hermes-api-install.mjs +2 -1
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +10 -3
- package/scripts/smoke/hermes-rest-codex-launch.mjs +4 -2
- package/scripts/smoke/hermes-settings-commands.mjs +71 -6
- package/scripts/smoke/pixcode-workbench-1-48.mjs +3 -1
- package/scripts/smoke/tunnel-persistence.mjs +56 -0
- package/scripts/smoke/vscode-workbench-polish.mjs +8 -2
- package/server/index.js +110 -38
- package/server/modules/orchestration/hermes/hermes.routes.ts +3 -0
- package/server/routes/network.js +2 -2
- package/server/services/external-access.js +199 -11
|
@@ -7,6 +7,8 @@ const baseUrl = (process.env.PIXCODE_BASE_URL || '').replace(/\/$/, '');
|
|
|
7
7
|
const apiKey = process.env.PIXCODE_API_KEY || '';
|
|
8
8
|
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
9
|
const mcpServerPath = path.join(appRoot, 'scripts', 'hermes', 'pixcode-mcp-server.mjs');
|
|
10
|
+
const READBACK_IDLE_STABLE_MS = 2500;
|
|
11
|
+
const DEFAULT_STARTUP_WAIT_MS = 100000;
|
|
10
12
|
|
|
11
13
|
const tools = [
|
|
12
14
|
{
|
|
@@ -35,7 +37,7 @@ const tools = [
|
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
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
|
|
40
|
+
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 continue the existing visible provider terminal in the project and submit startup input there. Do not run a parallel Hermes codex/claude/proc command for the same request. Do not request a fresh session unless the user explicitly asks for a new session. 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
41
|
inputSchema: {
|
|
40
42
|
type: 'object',
|
|
41
43
|
properties: {
|
|
@@ -55,6 +57,10 @@ const tools = [
|
|
|
55
57
|
type: 'string',
|
|
56
58
|
description: 'Exact startup input typed into the provider CLI after the TUI is ready. Use this for commands like /init, hello prompts, or full multi-step task instructions the user asked to run visibly.',
|
|
57
59
|
},
|
|
60
|
+
forceNewSession: {
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
description: 'Start a fresh visible provider CLI session only when the user explicitly asks for a new session. Omit or false to continue the existing visible provider terminal.',
|
|
63
|
+
},
|
|
58
64
|
bypassPermissions: {
|
|
59
65
|
type: 'boolean',
|
|
60
66
|
description: 'When true, Pixcode starts the provider CLI with its strongest no-approval/bypass flags where supported. Defaults to true for Hermes-launched visible task work.',
|
|
@@ -222,21 +228,21 @@ function inferTerminalState(provider, terminalOutput) {
|
|
|
222
228
|
if (!output.trim()) return 'unknown';
|
|
223
229
|
if (/Process exited with code/iu.test(output)) return 'idle';
|
|
224
230
|
|
|
225
|
-
const
|
|
226
|
-
|
|
231
|
+
const lastWeakBusy = getLastMatchIndex(output, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu);
|
|
232
|
+
const lastStrongBusy = Math.max(
|
|
227
233
|
getLastMatchIndex(output, /\bWorking\s*\([^)]*esc to interrupt[^)]*\)/giu),
|
|
228
234
|
getLastMatchIndex(output, /\bmsg=interrupt\b/giu),
|
|
229
235
|
);
|
|
236
|
+
const lastBusy = Math.max(lastWeakBusy, lastStrongBusy);
|
|
230
237
|
|
|
231
238
|
if (provider === 'codex') {
|
|
232
239
|
const lastPrompt = Math.max(
|
|
233
240
|
getLastMatchIndex(output, /(?:^|\n)\s*›(?:\s|$)/gu),
|
|
234
241
|
getLastMatchIndex(output, /(?:^|\n)\s*❯(?:\s|$)/gu),
|
|
235
242
|
);
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
: 'unknown';
|
|
243
|
+
if (lastPrompt >= 0) return lastStrongBusy > lastPrompt ? 'busy' : 'idle';
|
|
244
|
+
if (lastBusy >= 0) return 'busy';
|
|
245
|
+
return 'unknown';
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
if (lastBusy >= 0) return 'busy';
|
|
@@ -245,12 +251,30 @@ function inferTerminalState(provider, terminalOutput) {
|
|
|
245
251
|
|
|
246
252
|
function isTerminalReadbackFinal(provider, terminalOutput) {
|
|
247
253
|
const terminalState = inferTerminalState(provider, terminalOutput);
|
|
248
|
-
return terminalState === 'idle' || terminalState === 'completed' || terminalState === 'exited';
|
|
254
|
+
return terminalState === 'idle' || terminalState === 'completed' || terminalState === 'exited' || terminalState === 'failed';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isTerminalReadbackHardFinal(provider, terminalOutput) {
|
|
258
|
+
const terminalState = inferTerminalState(provider, terminalOutput);
|
|
259
|
+
return terminalState === 'completed' || terminalState === 'exited' || terminalState === 'failed' || Boolean(terminalOutput?.terminalFailed);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getReadbackFingerprint(terminalOutput) {
|
|
263
|
+
return [
|
|
264
|
+
terminalOutput?.terminalState || '',
|
|
265
|
+
terminalOutput?.lifecycleState || '',
|
|
266
|
+
terminalOutput?.exitCode ?? '',
|
|
267
|
+
terminalOutput?.exitSignal || '',
|
|
268
|
+
String(terminalOutput?.output || '').slice(-12000),
|
|
269
|
+
].join('\n---pixcode-readback---\n');
|
|
249
270
|
}
|
|
250
271
|
|
|
251
272
|
async function waitForProviderTerminalOutput(provider, projectPath, waitMs, launchId = null) {
|
|
252
273
|
const startedAt = Date.now();
|
|
253
274
|
let latestOutput = null;
|
|
275
|
+
let stableFingerprint = null;
|
|
276
|
+
let stableSince = 0;
|
|
277
|
+
let stableFinal = false;
|
|
254
278
|
do {
|
|
255
279
|
const elapsed = Date.now() - startedAt;
|
|
256
280
|
const remaining = Math.max(0, waitMs - elapsed);
|
|
@@ -262,7 +286,23 @@ async function waitForProviderTerminalOutput(provider, projectPath, waitMs, laun
|
|
|
262
286
|
}));
|
|
263
287
|
|
|
264
288
|
if (latestOutput?.output && isTerminalReadbackFinal(provider, latestOutput)) {
|
|
265
|
-
|
|
289
|
+
if (isTerminalReadbackHardFinal(provider, latestOutput)) {
|
|
290
|
+
stableFinal = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const fingerprint = getReadbackFingerprint(latestOutput);
|
|
295
|
+
if (fingerprint !== stableFingerprint) {
|
|
296
|
+
stableFingerprint = fingerprint;
|
|
297
|
+
stableSince = Date.now();
|
|
298
|
+
}
|
|
299
|
+
if (Date.now() - stableSince >= READBACK_IDLE_STABLE_MS) {
|
|
300
|
+
stableFinal = true;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
stableFingerprint = null;
|
|
305
|
+
stableSince = 0;
|
|
266
306
|
}
|
|
267
307
|
} while (Date.now() - startedAt < waitMs);
|
|
268
308
|
|
|
@@ -272,6 +312,10 @@ async function waitForProviderTerminalOutput(provider, projectPath, waitMs, laun
|
|
|
272
312
|
if (latestOutput && typeof latestOutput.isBusy !== 'boolean') {
|
|
273
313
|
latestOutput.isBusy = latestOutput.terminalState === 'busy';
|
|
274
314
|
}
|
|
315
|
+
if (latestOutput) {
|
|
316
|
+
latestOutput.readbackStable = stableFinal;
|
|
317
|
+
latestOutput.terminalOutputFinal = stableFinal;
|
|
318
|
+
}
|
|
275
319
|
return latestOutput;
|
|
276
320
|
}
|
|
277
321
|
|
|
@@ -285,13 +329,13 @@ function isLegacyPromptLikelyStartupInput(prompt) {
|
|
|
285
329
|
return prompt.length <= 80;
|
|
286
330
|
}
|
|
287
331
|
|
|
288
|
-
async function
|
|
332
|
+
async function upsertProviderPixcodeMcp(provider, projectPath, scope) {
|
|
289
333
|
const body = await pixcodeFetch(`/api/providers/${encodeURIComponent(provider)}/mcp/servers`, {
|
|
290
334
|
method: 'POST',
|
|
291
335
|
body: JSON.stringify({
|
|
292
336
|
name: 'pixcode',
|
|
293
337
|
transport: 'stdio',
|
|
294
|
-
scope
|
|
338
|
+
scope,
|
|
295
339
|
workspacePath: projectPath || process.cwd(),
|
|
296
340
|
command: process.execPath,
|
|
297
341
|
args: [mcpServerPath],
|
|
@@ -304,6 +348,22 @@ async function ensureProviderPixcodeMcp(provider, projectPath) {
|
|
|
304
348
|
return body?.data?.server ?? body?.server ?? body;
|
|
305
349
|
}
|
|
306
350
|
|
|
351
|
+
async function ensureProviderPixcodeMcp(provider, projectPath) {
|
|
352
|
+
try {
|
|
353
|
+
const server = await upsertProviderPixcodeMcp(provider, projectPath, 'project');
|
|
354
|
+
return { scope: 'project', server, projectScopeError: null };
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const projectScopeError = error instanceof Error ? error.message : String(error);
|
|
357
|
+
try {
|
|
358
|
+
const server = await upsertProviderPixcodeMcp(provider, projectPath, 'user');
|
|
359
|
+
return { scope: 'user', server, projectScopeError };
|
|
360
|
+
} catch (fallbackError) {
|
|
361
|
+
const userScopeError = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
362
|
+
throw new Error(`Pixcode MCP auto-config failed for project scope (${projectScopeError}) and user scope (${userScopeError})`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
async function callTool(name, args = {}) {
|
|
308
368
|
if (name === 'pixcode_list_projects') {
|
|
309
369
|
const projects = await pixcodeFetch('/api/projects');
|
|
@@ -339,9 +399,10 @@ async function callTool(name, args = {}) {
|
|
|
339
399
|
}
|
|
340
400
|
|
|
341
401
|
let mcpConfigured = false;
|
|
402
|
+
let mcpConfig = null;
|
|
342
403
|
let mcpError = null;
|
|
343
404
|
try {
|
|
344
|
-
await ensureProviderPixcodeMcp(provider, projectPath);
|
|
405
|
+
mcpConfig = await ensureProviderPixcodeMcp(provider, projectPath);
|
|
345
406
|
mcpConfigured = true;
|
|
346
407
|
} catch (error) {
|
|
347
408
|
mcpError = error instanceof Error ? error.message : String(error);
|
|
@@ -351,6 +412,7 @@ async function callTool(name, args = {}) {
|
|
|
351
412
|
? args.startupInput
|
|
352
413
|
: (isLegacyPromptLikelyStartupInput(args.prompt) ? args.prompt.trim() : null);
|
|
353
414
|
const bypassPermissions = args.bypassPermissions === false ? false : true;
|
|
415
|
+
const forceNewSession = args.forceNewSession === true || args.newSession === true || args.freshSession === true;
|
|
354
416
|
const permissionMode = typeof args.permissionMode === 'string' && args.permissionMode.trim()
|
|
355
417
|
? args.permissionMode.trim()
|
|
356
418
|
: (bypassPermissions ? 'bypassPermissions' : null);
|
|
@@ -362,6 +424,7 @@ async function callTool(name, args = {}) {
|
|
|
362
424
|
projectPath,
|
|
363
425
|
prompt: args.prompt || null,
|
|
364
426
|
startupInput,
|
|
427
|
+
forceNewSession,
|
|
365
428
|
bypassPermissions,
|
|
366
429
|
skipPermissions: bypassPermissions,
|
|
367
430
|
permissionMode,
|
|
@@ -369,24 +432,31 @@ async function callTool(name, args = {}) {
|
|
|
369
432
|
});
|
|
370
433
|
const launchId = Number(body?.event?.id || body?.id || 0) || null;
|
|
371
434
|
let terminalOutput = null;
|
|
372
|
-
const defaultWaitMs = startupInput ?
|
|
435
|
+
const defaultWaitMs = startupInput ? DEFAULT_STARTUP_WAIT_MS : 0;
|
|
373
436
|
const requestedWaitMs = Number(args.waitForCompletionMs ?? args.waitForOutputMs ?? defaultWaitMs);
|
|
374
437
|
const waitForOutputMs = Math.min(600000, Math.max(0, requestedWaitMs));
|
|
375
438
|
if (waitForOutputMs > 0) {
|
|
376
439
|
terminalOutput = await waitForProviderTerminalOutput(provider, projectPath, waitForOutputMs, launchId);
|
|
377
440
|
}
|
|
378
|
-
const terminalOutputFinal = terminalOutput
|
|
441
|
+
const terminalOutputFinal = terminalOutput
|
|
442
|
+
? Boolean(terminalOutput.terminalOutputFinal ?? isTerminalReadbackFinal(provider, terminalOutput))
|
|
443
|
+
: false;
|
|
379
444
|
return textResult(JSON.stringify({
|
|
380
445
|
launched: true,
|
|
381
446
|
launchId,
|
|
382
447
|
pixcodeMcpConfigured: mcpConfigured,
|
|
448
|
+
pixcodeMcpScope: mcpConfig?.scope ?? null,
|
|
449
|
+
pixcodeMcpProjectScopeError: mcpConfig?.projectScopeError ?? null,
|
|
383
450
|
pixcodeMcpError: mcpError,
|
|
384
451
|
event: body?.event ?? body,
|
|
385
452
|
permissionBypass: bypassPermissions,
|
|
386
453
|
status,
|
|
387
454
|
terminalOutputFinal,
|
|
455
|
+
terminalFailed: Boolean(terminalOutput?.terminalFailed),
|
|
388
456
|
message: terminalOutput && !terminalOutputFinal
|
|
389
457
|
? '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.'
|
|
458
|
+
: terminalOutput?.terminalFailed
|
|
459
|
+
? 'Provider terminal exited with a failure. Do not report this as successful; tell the user the visible CLI failed and include the exit code/output.'
|
|
390
460
|
: undefined,
|
|
391
461
|
terminalOutput,
|
|
392
462
|
}, null, 2));
|
|
@@ -407,6 +477,7 @@ async function callTool(name, args = {}) {
|
|
|
407
477
|
body.isBusy = body.terminalState === 'busy';
|
|
408
478
|
}
|
|
409
479
|
body.terminalOutputFinal = isTerminalReadbackFinal(provider, body);
|
|
480
|
+
body.terminalFailed = Boolean(body.terminalFailed);
|
|
410
481
|
return textResult(JSON.stringify(body, null, 2));
|
|
411
482
|
}
|
|
412
483
|
|
|
@@ -37,6 +37,7 @@ assert.match(hermesInstallJobs, /windowsCmdFallbackCommand/, 'Hermes install sho
|
|
|
37
37
|
assert.match(hermesInstallJobs, /retrying through cmd\.exe without elevation/, 'Hermes install logs should explain the no-admin Windows EPERM fallback.');
|
|
38
38
|
assert.match(read('scripts/hermes/configure-pixcode-mcp.mjs'), /platform_toolsets/, 'Pixcode MCP config should enable the Pixcode toolset for Hermes API server runs.');
|
|
39
39
|
assert.match(read('scripts/hermes/configure-pixcode-mcp.mjs'), /api_server:[\s\S]+pixcode/, 'Hermes API server should see Pixcode MCP tools during /v1/runs.');
|
|
40
|
+
assert.match(read('scripts/hermes/configure-pixcode-mcp.mjs'), /const cliToolsets = \['mcp-pixcode'\]/, 'Hermes CLI profile should use Pixcode MCP-only tools by default so provider CLIs stay visible.');
|
|
40
41
|
assert.match(serverIndex, /buildHermesPathEnv\(process\.env/, 'Shell PTYs should inherit Hermes bin directories so typing hermes in Pixcode terminal works on Windows.');
|
|
41
42
|
assert.match(serverIndex, /env: shellEnv/, 'Shell PTYs should use the augmented shell environment instead of raw process.env.');
|
|
42
43
|
assert.doesNotMatch(serverIndex, /pixcode:hermes:start/, 'Hermes H button should not depend on a shell sentinel.');
|
|
@@ -46,7 +47,7 @@ assert.match(workbench, /HermesActivityButton/, 'Workbench activity rail should
|
|
|
46
47
|
assert.match(workbench, /command=\{hermesCommand\}/, 'Opening the Hermes H button should show the real Hermes terminal/TUI.');
|
|
47
48
|
assert.match(workbench, /bottomTerminalCommand/, 'Hermes settings should be able to launch Hermes subcommands in the bottom terminal.');
|
|
48
49
|
assert.match(workbench, /openBottomTerminal\('hermes'\)/, 'Opening Hermes should restore the Hermes terminal.');
|
|
49
|
-
assert.match(workbench, /openBottomTerminal\('hermes', \{
|
|
50
|
+
assert.match(workbench, /openBottomTerminal\('hermes', \{[\s\S]*command: HERMES_DEFAULT_COMMAND,[\s\S]*forceNewSession: true,[\s\S]*\}\)/, 'The Hermes new-session action should reset the Hermes terminal session with the guarded default command.');
|
|
50
51
|
assert.match(workbench, /installLogRef/, 'Hermes install log panel should keep a scroll ref.');
|
|
51
52
|
assert.match(workbench, /scrollTop = installLogRef\.current\.scrollHeight/, 'Hermes install logs should auto-scroll to the latest line.');
|
|
52
53
|
assert.doesNotMatch(workbench, /suspendAutoConnect/, 'Right CLI launches should not be blocked by an open Hermes bottom terminal.');
|
|
@@ -122,6 +122,13 @@ const server = createServer(async (req, res) => {
|
|
|
122
122
|
terminalState: 'busy',
|
|
123
123
|
output: 'OpenAI Codex\n› /init\n\n• Ran npm test\n• Working (30s • esc to interrupt)\n',
|
|
124
124
|
},
|
|
125
|
+
{
|
|
126
|
+
active: true,
|
|
127
|
+
provider: 'codex',
|
|
128
|
+
projectPath: '/root/pixcode',
|
|
129
|
+
terminalState: 'idle',
|
|
130
|
+
output: 'OpenAI Codex\n› /init\n\n• Ran npm test\n\n› Implement {feature}\n',
|
|
131
|
+
},
|
|
125
132
|
{
|
|
126
133
|
active: true,
|
|
127
134
|
provider: 'codex',
|
|
@@ -155,7 +162,7 @@ function callMcp(child, method, params = undefined) {
|
|
|
155
162
|
return new Promise((resolve, reject) => {
|
|
156
163
|
const timeout = setTimeout(() => {
|
|
157
164
|
reject(new Error(`MCP call timed out: ${method}`));
|
|
158
|
-
},
|
|
165
|
+
}, 10000);
|
|
159
166
|
const onLine = (line) => {
|
|
160
167
|
if (!line.trim()) return;
|
|
161
168
|
const message = JSON.parse(line);
|
|
@@ -232,11 +239,11 @@ try {
|
|
|
232
239
|
projectPath: '/root/pixcode',
|
|
233
240
|
prompt: 'read final output',
|
|
234
241
|
startupInput: '/init',
|
|
235
|
-
waitForOutputMs:
|
|
242
|
+
waitForOutputMs: 7000,
|
|
236
243
|
},
|
|
237
244
|
});
|
|
238
245
|
assert(
|
|
239
|
-
providerOutputReads.length >=
|
|
246
|
+
providerOutputReads.length >= 5,
|
|
240
247
|
`readback should keep polling until the provider terminal is idle, reads=${providerOutputReads.length}`,
|
|
241
248
|
);
|
|
242
249
|
assert.match(
|
|
@@ -72,6 +72,7 @@ const server = createServer(async (req, res) => {
|
|
|
72
72
|
projectPath: body.projectPath,
|
|
73
73
|
prompt: body.prompt,
|
|
74
74
|
startupInput: body.startupInput,
|
|
75
|
+
forceNewSession: Boolean(body.forceNewSession),
|
|
75
76
|
permissionMode: body.permissionMode,
|
|
76
77
|
skipPermissions: Boolean(body.skipPermissions),
|
|
77
78
|
bypassPermissions: Boolean(body.bypassPermissions),
|
|
@@ -175,10 +176,10 @@ try {
|
|
|
175
176
|
instructions: [
|
|
176
177
|
'You are testing Pixcode integration.',
|
|
177
178
|
'Use the MCP tool named mcp_pixcode_pixcode_open_cli_terminal exactly once.',
|
|
178
|
-
'Call it with provider="codex", the supplied projectPath, startupInput equal to the supplied task, prompt="Pixcode Hermes REST smoke visible Codex task", and bypassPermissions=true.',
|
|
179
|
+
'Call it with provider="codex", the supplied projectPath, startupInput equal to the supplied task, prompt="Pixcode Hermes REST smoke visible Codex task", forceNewSession=false, and bypassPermissions=true.',
|
|
179
180
|
'After the tool call, answer with "codex launch requested".',
|
|
180
181
|
].join(' '),
|
|
181
|
-
input: `Call mcp_pixcode_pixcode_open_cli_terminal with provider codex, projectPath ${JSON.stringify(projectPath)}, startupInput ${JSON.stringify(codexTask)}, prompt "Pixcode Hermes REST smoke visible Codex task", and bypassPermissions true.`,
|
|
182
|
+
input: `Call mcp_pixcode_pixcode_open_cli_terminal with provider codex, projectPath ${JSON.stringify(projectPath)}, startupInput ${JSON.stringify(codexTask)}, prompt "Pixcode Hermes REST smoke visible Codex task", forceNewSession false, and bypassPermissions true.`,
|
|
182
183
|
}),
|
|
183
184
|
});
|
|
184
185
|
if (!response.ok || !body?.run_id) {
|
|
@@ -195,6 +196,7 @@ try {
|
|
|
195
196
|
assert.equal(launch.bypassPermissions, true, 'Hermes Codex launch should request provider permission bypass.');
|
|
196
197
|
assert.equal(launch.skipPermissions, true, 'Hermes Codex launch should carry skipPermissions for providers that use skip flags.');
|
|
197
198
|
assert.equal(launch.permissionMode, 'bypassPermissions', 'Hermes Codex launch should use bypassPermissions mode.');
|
|
199
|
+
assert.equal(launch.forceNewSession, false, 'Hermes Codex launch should continue the visible provider terminal unless a fresh session is explicit.');
|
|
198
200
|
console.log(JSON.stringify({
|
|
199
201
|
ok: true,
|
|
200
202
|
runId: body.run_id,
|
|
@@ -55,8 +55,8 @@ assert.match(
|
|
|
55
55
|
);
|
|
56
56
|
assert.match(
|
|
57
57
|
workbench,
|
|
58
|
-
/HERMES_DEFAULT_COMMAND\s*=\s*'hermes --yolo'/,
|
|
59
|
-
'Hermes terminal should default to
|
|
58
|
+
/HERMES_DEFAULT_COMMAND\s*=\s*'hermes --yolo --toolsets mcp-pixcode'/,
|
|
59
|
+
'Hermes terminal should default to Pixcode MCP-only toolsets so provider CLIs stay visible inside Pixcode.',
|
|
60
60
|
);
|
|
61
61
|
assert.match(
|
|
62
62
|
serverIndex,
|
|
@@ -85,8 +85,18 @@ assert.match(
|
|
|
85
85
|
);
|
|
86
86
|
assert.match(
|
|
87
87
|
pixcodeMcpServer,
|
|
88
|
-
/
|
|
89
|
-
'Pixcode MCP
|
|
88
|
+
/DEFAULT_STARTUP_WAIT_MS\s*=\s*100000/,
|
|
89
|
+
'Pixcode MCP default completion wait should stay under the Hermes MCP tool timeout.',
|
|
90
|
+
);
|
|
91
|
+
assert.match(
|
|
92
|
+
pixcodeMcpServer,
|
|
93
|
+
/lastStrongBusy[\s\S]+lastPrompt[\s\S]+\?\s*'busy'\s*:\s*'idle'/,
|
|
94
|
+
'Codex readback should ignore weak spinner remnants once the prompt has returned.',
|
|
95
|
+
);
|
|
96
|
+
assert.match(
|
|
97
|
+
serverIndex,
|
|
98
|
+
/lastStrongBusy[\s\S]+lastPrompt[\s\S]+\?\s*'busy'\s*:\s*'idle'/,
|
|
99
|
+
'Backend provider-output should ignore weak Codex spinner remnants once the prompt has returned.',
|
|
90
100
|
);
|
|
91
101
|
assert.match(
|
|
92
102
|
pixcodeMcpServer,
|
|
@@ -98,6 +108,31 @@ assert.match(
|
|
|
98
108
|
/requestedLaunchId[\s\S]+session\.hermesLaunchId === requestedLaunchId/,
|
|
99
109
|
'Provider output API should filter by Hermes terminal launch id when supplied.',
|
|
100
110
|
);
|
|
111
|
+
assert.match(
|
|
112
|
+
serverIndex,
|
|
113
|
+
/lifecycleState/,
|
|
114
|
+
'Provider output API should expose provider-agnostic PTY lifecycle state instead of relying only on terminal text regex.',
|
|
115
|
+
);
|
|
116
|
+
assert.match(
|
|
117
|
+
serverIndex,
|
|
118
|
+
/terminalFailed/,
|
|
119
|
+
'Provider output API should expose non-zero visible terminal exits as failures for Hermes readback.',
|
|
120
|
+
);
|
|
121
|
+
assert.match(
|
|
122
|
+
serverIndex,
|
|
123
|
+
/existingSession[\s\S]+existingSession\.pty/,
|
|
124
|
+
'Completed visible terminal records should not be reattached as live PTYs.',
|
|
125
|
+
);
|
|
126
|
+
assert.match(
|
|
127
|
+
serverIndex,
|
|
128
|
+
/session\.pty\s*!==\s*shellProcess/,
|
|
129
|
+
'Stale PTY exit handlers should not mark a replacement provider session as completed or failed.',
|
|
130
|
+
);
|
|
131
|
+
assert.match(
|
|
132
|
+
pixcodeMcpServer,
|
|
133
|
+
/terminalFailed/,
|
|
134
|
+
'Pixcode MCP should tell Hermes when the visible provider terminal failed.',
|
|
135
|
+
);
|
|
101
136
|
assert.match(
|
|
102
137
|
serverIndex,
|
|
103
138
|
/const hermesLaunchId = Number\.isFinite\(Number\(data\.hermesLaunchId\)\)/,
|
|
@@ -113,6 +148,21 @@ assert.match(
|
|
|
113
148
|
/terminalState is busy|terminalState.+busy|terminal to become idle/i,
|
|
114
149
|
'Pixcode MCP should not summarize the first busy terminal frame as final output.',
|
|
115
150
|
);
|
|
151
|
+
assert.match(
|
|
152
|
+
pixcodeMcpServer,
|
|
153
|
+
/READBACK_IDLE_STABLE_MS/,
|
|
154
|
+
'Pixcode MCP should require a stable idle readback before reporting provider output as final.',
|
|
155
|
+
);
|
|
156
|
+
assert.match(
|
|
157
|
+
pixcodeMcpServer,
|
|
158
|
+
/readbackStable/,
|
|
159
|
+
'Pixcode MCP should mark whether a visible provider readback was stable before Hermes summarizes it.',
|
|
160
|
+
);
|
|
161
|
+
assert.match(
|
|
162
|
+
pixcodeMcpServer,
|
|
163
|
+
/scope:\s*'user'/,
|
|
164
|
+
'Pixcode MCP provider auto-config should fall back to user scope when project scope cannot be written.',
|
|
165
|
+
);
|
|
116
166
|
assert.match(
|
|
117
167
|
pixcodeMcpServer,
|
|
118
168
|
/startup input typed into the provider CLI/,
|
|
@@ -170,14 +220,29 @@ assert.match(
|
|
|
170
220
|
);
|
|
171
221
|
assert.match(
|
|
172
222
|
shellConnection,
|
|
173
|
-
/provider === 'codex'[\s\S]+
|
|
174
|
-
'Codex startup input should be
|
|
223
|
+
/provider === 'codex'[\s\S]+forceNewSessionRef\.current[\s\S]+startupInputForCommand/,
|
|
224
|
+
'Codex startup input should be passed as a process argument only for explicit fresh sessions.',
|
|
175
225
|
);
|
|
176
226
|
assert.match(
|
|
177
227
|
shellConnection,
|
|
178
228
|
/startupInput:\s*handlesStartupInputInCommand \? startupInputForCommand : null/,
|
|
179
229
|
'Shell websocket init should send Codex startup input to the backend command builder.',
|
|
180
230
|
);
|
|
231
|
+
assert.match(
|
|
232
|
+
workbench,
|
|
233
|
+
/forceNewSession:\s*hermesCliLaunch\.forceNewSession === true/,
|
|
234
|
+
'Hermes-launched provider work should continue the current visible terminal unless forceNewSession is explicit.',
|
|
235
|
+
);
|
|
236
|
+
assert.match(
|
|
237
|
+
pixcodeMcpServer,
|
|
238
|
+
/forceNewSession/,
|
|
239
|
+
'Pixcode MCP should let Hermes request a fresh visible provider session only when the user asks for one.',
|
|
240
|
+
);
|
|
241
|
+
assert.match(
|
|
242
|
+
pixcodeMcpServer,
|
|
243
|
+
/continue the existing visible provider terminal/i,
|
|
244
|
+
'Pixcode MCP tool instructions should tell Hermes to continue existing visible provider terminals by default.',
|
|
245
|
+
);
|
|
181
246
|
assert.doesNotMatch(
|
|
182
247
|
workbench,
|
|
183
248
|
/hermesCliLaunch\.startupInput \? `\$\{hermesCliLaunch\.startupInput\}\\r` : null/,
|
|
@@ -73,13 +73,14 @@ assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selecte
|
|
|
73
73
|
assert.doesNotMatch(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should not launch from the bottom terminal through a server-side sentinel.');
|
|
74
74
|
assert.doesNotMatch(workbench, /HermesApiChatPanel|HermesTerminalTranscript/, 'Hermes Agent should use the real PTY terminal UI, not a custom REST chat transcript.');
|
|
75
75
|
assert.doesNotMatch(workbench, /REST POST \/|transport=|response=|gateway=http/, 'Hermes terminal UI must not expose REST debug internals to the user.');
|
|
76
|
-
assert.match(workbench, /HERMES_DEFAULT_COMMAND = 'hermes --yolo'/, 'Hermes Agent bottom panel should launch the actual `hermes` CLI
|
|
76
|
+
assert.match(workbench, /HERMES_DEFAULT_COMMAND = 'hermes --yolo --toolsets mcp-pixcode'/, 'Hermes Agent bottom panel should launch the actual `hermes` CLI with Pixcode MCP-only tools.');
|
|
77
77
|
assert.match(workbench, /HERMES_HISTORY_COMMAND = 'hermes sessions browse'/, 'Hermes terminal history should open the native interactive session picker.');
|
|
78
78
|
assert.match(workbench, /onOpenHistory=\{openHermesHistory\}/, 'Hermes terminal header should wire its history button to the native Hermes sessions command.');
|
|
79
79
|
assert.match(workbench, /Pixcode MCP Live/, 'Hermes terminal should show a user-facing Pixcode MCP live badge.');
|
|
80
80
|
assert.doesNotMatch(workbench, /ml-auto border-blue-500\/40 bg-blue-500\/10/, 'Hermes REST panel must not use right-aligned chat bubbles.');
|
|
81
81
|
assert.match(workbench, /terminal-launches\/stream/, 'Hermes CLI launch requests should arrive through an EventSource stream.');
|
|
82
82
|
assert.doesNotMatch(workbench, /HERMES_TERMINAL_LAUNCH_POLL_MS|setInterval\([\s\S]*terminal-launches/, 'Hermes CLI launch requests should not be polled every few seconds.');
|
|
83
|
+
assert.match(workbench, /forceNewSession:\s*hermesCliLaunch\.forceNewSession === true/, 'Hermes MCP provider launches should continue the current visible CLI session by default.');
|
|
83
84
|
assert.doesNotMatch(workbench, /Project-scoped agent terminal\. Installs Hermes when missing/, 'Right CLI panel should not show the old Hermes card.');
|
|
84
85
|
assert.doesNotMatch(workbench, /vscodeWorkbench\.hermes\.docsShort|HERMES_AGENT_DOCS_URL/, 'Hermes terminal header should not include a docs shortcut.');
|
|
85
86
|
assert.match(workbench, /shrinkCliPanel/, 'Right CLI panel should expose a shrink action.');
|
|
@@ -135,6 +136,7 @@ assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install s
|
|
|
135
136
|
assert.match(workbench, /terminalStartupInput/, 'Hermes terminal launch prompts should be passed into the selected CLI.');
|
|
136
137
|
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /startupInputRef/, 'Shell connections should support one-shot startup input for Hermes-triggered CLI work.');
|
|
137
138
|
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /isCliReadyForStartupInput/, 'Hermes-triggered CLI input should wait until the provider TUI is ready.');
|
|
139
|
+
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /forceNewSessionRef\.current[\s\S]+startupInputForCommand/, 'Codex startup input should only be sent as a CLI argument for explicit fresh sessions.');
|
|
138
140
|
assert.doesNotMatch(read('src/components/shell/hooks/useShellConnection.ts'), /TERMINAL_INIT_DELAY_MS \* 3/, 'Hermes-triggered CLI input should not be sent on a blind fixed delay.');
|
|
139
141
|
assert.match(shellTerminal, /handleTerminalPaste/, 'Terminal should support browser paste events.');
|
|
140
142
|
assert.match(shellTerminal, /handleCopyPasteShortcut/, 'Terminal should normalize Ctrl/Cmd copy and paste shortcuts.');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const externalAccess = readFileSync('server/services/external-access.js', 'utf8');
|
|
7
|
+
const networkRoutes = readFileSync('server/routes/network.js', 'utf8');
|
|
8
|
+
const serverIndex = readFileSync('server/index.js', 'utf8');
|
|
9
|
+
|
|
10
|
+
assert.match(
|
|
11
|
+
externalAccess,
|
|
12
|
+
/TUNNEL_PERSISTENCE_PATH/,
|
|
13
|
+
'Tunnel service should persist the user-requested tunnel state outside process memory.',
|
|
14
|
+
);
|
|
15
|
+
assert.match(
|
|
16
|
+
externalAccess,
|
|
17
|
+
/persistTunnelPreference/,
|
|
18
|
+
'Tunnel service should write tunnel start/stop intent to disk.',
|
|
19
|
+
);
|
|
20
|
+
assert.match(
|
|
21
|
+
externalAccess,
|
|
22
|
+
/desired:\s*true/,
|
|
23
|
+
'Starting a tunnel should mark tunnel intent as desired until the user stops it.',
|
|
24
|
+
);
|
|
25
|
+
assert.match(
|
|
26
|
+
externalAccess,
|
|
27
|
+
/desired:\s*false/,
|
|
28
|
+
'Stopping a tunnel should clear persisted tunnel intent.',
|
|
29
|
+
);
|
|
30
|
+
assert.match(
|
|
31
|
+
externalAccess,
|
|
32
|
+
/restoreRequestedTunnel/,
|
|
33
|
+
'Tunnel service should expose a startup restore hook.',
|
|
34
|
+
);
|
|
35
|
+
assert.match(
|
|
36
|
+
externalAccess,
|
|
37
|
+
/restoring/,
|
|
38
|
+
'Tunnel restore should distinguish automatic restart attempts from direct user starts.',
|
|
39
|
+
);
|
|
40
|
+
assert.match(
|
|
41
|
+
networkRoutes,
|
|
42
|
+
/persistPreference:\s*true/,
|
|
43
|
+
'Manual tunnel starts should persist the user preference through the network route.',
|
|
44
|
+
);
|
|
45
|
+
assert.match(
|
|
46
|
+
serverIndex,
|
|
47
|
+
/restoreRequestedTunnel/,
|
|
48
|
+
'Server startup should restore a requested tunnel after updates/restarts.',
|
|
49
|
+
);
|
|
50
|
+
assert.match(
|
|
51
|
+
serverIndex,
|
|
52
|
+
/restoreRequestedTunnel\(\{ port: Number\(SERVER_PORT\) \}\)/,
|
|
53
|
+
'Server startup should restore the tunnel against the current backend port.',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
console.log('tunnel persistence smoke passed');
|
|
@@ -266,6 +266,12 @@ assert.match(
|
|
|
266
266
|
'Right CLI panel should mark explicitly started new sessions so the backend does not reconnect the old PTY.',
|
|
267
267
|
);
|
|
268
268
|
|
|
269
|
+
assert.match(
|
|
270
|
+
workbench,
|
|
271
|
+
/forceNewSession:\s*hermesCliLaunch\.forceNewSession === true/,
|
|
272
|
+
'Hermes-triggered provider work should reuse the current visible CLI session unless a fresh session is explicitly requested.',
|
|
273
|
+
);
|
|
274
|
+
|
|
269
275
|
assert.match(
|
|
270
276
|
workbench,
|
|
271
277
|
/function WorkbenchCliPanelToolbar/,
|
|
@@ -308,8 +314,8 @@ assert.doesNotMatch(
|
|
|
308
314
|
|
|
309
315
|
assert.match(
|
|
310
316
|
workbench,
|
|
311
|
-
/HERMES_DEFAULT_COMMAND = 'hermes --yolo'/,
|
|
312
|
-
'Hermes Agent should launch the Hermes CLI directly in bypass mode.',
|
|
317
|
+
/HERMES_DEFAULT_COMMAND = 'hermes --yolo --toolsets mcp-pixcode'/,
|
|
318
|
+
'Hermes Agent should launch the Hermes CLI directly in bypass mode with only the Pixcode MCP toolset.',
|
|
313
319
|
);
|
|
314
320
|
|
|
315
321
|
assert.match(
|