@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.
@@ -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 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.',
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 lastBusy = Math.max(
226
- getLastMatchIndex(output, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu),
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 (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';
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
- break;
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 ensureProviderPixcodeMcp(provider, projectPath) {
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: 'project',
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 ? 180000 : 0;
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 ? isTerminalReadbackFinal(provider, terminalOutput) : false;
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', \{ command: 'hermes', forceNewSession: true \}\)/, 'The Hermes new-session action should reset the Hermes terminal session.');
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
- }, 5000);
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: 3000,
242
+ waitForOutputMs: 7000,
236
243
  },
237
244
  });
238
245
  assert(
239
- providerOutputReads.length >= 3,
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 --yolo so Hermes approval prompts do not stop visible work.',
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
- /defaultWaitMs\s*=\s*startupInput \? 180000 : 0/,
89
- 'Pixcode MCP should wait for visible provider completion by default when startupInput is present.',
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]+startupInputRef\.current/,
174
- 'Codex startup input should be handled at process launch instead of typed into an already-open TUI.',
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 in a bypass-enabled PTY.');
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(