@pixelbyte-software/pixcode 1.50.3 → 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.
@@ -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 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. If the user asks for the provider output, set waitForOutputMs or call pixcode_read_cli_terminal after launch.',
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 and then read recent terminal output. Useful when the user asks you to report the provider output.',
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 waitForOutputMs = Math.min(15000, Math.max(0, Number(args.waitForOutputMs || 0)));
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
- const startedAt = Date.now();
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 body = await readProviderTerminalOutput(provider, projectPath, maxChars);
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: 1,
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, 1, 'Only the installed Codex provider should be launched');
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
  });
@@ -83,6 +83,36 @@ assert.match(
83
83
  /multi-step|piece-by-piece|long-running/i,
84
84
  'Pixcode MCP should tell Hermes to send arbitrary multi-step work as visible provider terminal input.',
85
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
+ );
86
116
  assert.match(
87
117
  pixcodeMcpServer,
88
118
  /startup input typed into the provider CLI/,
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
  }
@@ -532,6 +603,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
532
603
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
533
604
  ? req.query.projectPath.trim()
534
605
  : null;
606
+ const launchId = Number.parseInt(String(req.query.launchId || ''), 10);
607
+ const requestedLaunchId = Number.isFinite(launchId) && launchId > 0 ? launchId : null;
535
608
  const maxChars = Math.min(
536
609
  20000,
537
610
  Math.max(1000, Number.parseInt(String(req.query.maxChars || '12000'), 10) || 12000)
@@ -547,7 +620,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
547
620
  if (
548
621
  session?.provider === provider &&
549
622
  !session?.isPlainShell &&
550
- (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath)
623
+ (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
624
+ (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
551
625
  ) {
552
626
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
553
627
  matchedSession = session;
@@ -560,19 +634,24 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
560
634
  active: false,
561
635
  provider,
562
636
  projectPath: requestedProjectPath,
637
+ launchId: requestedLaunchId,
563
638
  output: '',
564
639
  message: 'No active provider terminal session found for this project.',
565
640
  });
566
641
  }
567
642
 
568
643
  const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
644
+ const output = stripAnsiSequences(rawOutput);
645
+ const terminalState = detectProviderTerminalState(provider, output);
569
646
  res.json({
570
647
  active: true,
571
648
  provider,
572
649
  projectPath: path.resolve(matchedSession.projectPath || os.homedir()),
573
650
  sessionId: matchedSession.sessionId || null,
651
+ launchId: matchedSession.hermesLaunchId || null,
574
652
  updatedAt: matchedSession.updatedAt || null,
575
- output: stripAnsiSequences(rawOutput),
653
+ ...terminalState,
654
+ output,
576
655
  });
577
656
  });
578
657
 
@@ -2268,6 +2347,9 @@ function handleShellConnection(ws, request) {
2268
2347
  const startupInput = typeof data.startupInput === 'string' && data.startupInput.trim()
2269
2348
  ? data.startupInput.trim()
2270
2349
  : null;
2350
+ const hermesLaunchId = Number.isFinite(Number(data.hermesLaunchId)) && Number(data.hermesLaunchId) > 0
2351
+ ? Number(data.hermesLaunchId)
2352
+ : null;
2271
2353
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2272
2354
  const isHermesCliLaunch = isPlainShell && isHermesCliCommand(initialCommand);
2273
2355
  const forceNewSession = Boolean(data.forceNewSession);
@@ -2548,6 +2630,7 @@ function handleShellConnection(ws, request) {
2548
2630
  timeoutId: null,
2549
2631
  projectPath,
2550
2632
  sessionId,
2633
+ hermesLaunchId,
2551
2634
  provider,
2552
2635
  isPlainShell,
2553
2636
  keepAliveUntilExit: false,