@pixelbyte-software/pixcode 1.33.9 → 1.33.11

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.
Files changed (38) hide show
  1. package/dist/api-docs.html +373 -857
  2. package/dist/assets/{index-DpIcI9Q1.js → index-oLYHJ2X5.js} +154 -166
  3. package/dist/index.html +1 -1
  4. package/dist/openapi.yaml +1311 -0
  5. package/dist-server/server/gemini-cli.js +59 -0
  6. package/dist-server/server/gemini-cli.js.map +1 -1
  7. package/dist-server/server/index.js +6 -1
  8. package/dist-server/server/index.js.map +1 -1
  9. package/dist-server/server/middleware/auth.js +51 -9
  10. package/dist-server/server/middleware/auth.js.map +1 -1
  11. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +54 -15
  12. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  13. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +46 -0
  14. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -1
  15. package/dist-server/server/modules/providers/provider.routes.js +32 -1
  16. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  17. package/dist-server/server/opencode-cli.js +41 -2
  18. package/dist-server/server/opencode-cli.js.map +1 -1
  19. package/dist-server/server/opencode-response-handler.js +36 -34
  20. package/dist-server/server/opencode-response-handler.js.map +1 -1
  21. package/dist-server/server/routes/agent.js +187 -56
  22. package/dist-server/server/routes/agent.js.map +1 -1
  23. package/dist-server/server/routes/projects.js +134 -8
  24. package/dist-server/server/routes/projects.js.map +1 -1
  25. package/dist-server/server/services/provider-credentials.js +42 -8
  26. package/dist-server/server/services/provider-credentials.js.map +1 -1
  27. package/package.json +1 -1
  28. package/server/gemini-cli.js +60 -0
  29. package/server/index.js +6 -1
  30. package/server/middleware/auth.js +50 -9
  31. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +60 -21
  32. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +47 -0
  33. package/server/modules/providers/provider.routes.ts +37 -4
  34. package/server/opencode-cli.js +41 -2
  35. package/server/opencode-response-handler.js +36 -29
  36. package/server/routes/agent.js +178 -58
  37. package/server/routes/projects.js +136 -8
  38. package/server/services/provider-credentials.js +42 -8
@@ -16,8 +16,15 @@ const PROVIDER = 'opencode';
16
16
  * captured streams); restoring historical sessions from disk will land
17
17
  * in a follow-up once the exact schema is pinned.
18
18
  *
19
- * Stream events follow the headless `opencode serve` API contract —
20
- * messages carry `{ role, parts: [{ type: text|tool-use|tool-result, ... }] }`.
19
+ * Stream shape from `opencode run --format json` (verified against
20
+ * opencode-ai 0.x at the wire):
21
+ * { type:"step_start", timestamp, sessionID, part:{ type:"step-start", ... } }
22
+ * { type:"text", timestamp, sessionID, part:{ type:"text", text:"…", time:{…}, metadata:{…} } }
23
+ * { type:"tool_use", timestamp, sessionID, part:{ type:"tool-use", callID, tool, state:{ input } } }
24
+ * { type:"tool_result", timestamp, sessionID, part:{ type:"tool-result", callID, state:{ output, status } } }
25
+ * { type:"step_finish", timestamp, sessionID, part:{ type:"step-finish", reason, tokens, cost } }
26
+ * { type:"error", timestamp, sessionID, error:{ name, data:{ message, statusCode? } } }
27
+ * Field names are camelCase (`sessionID`, `callID`) — NOT snake_case.
21
28
  */
22
29
  export class OpencodeSessionsProvider implements IProviderSessions {
23
30
  normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
@@ -26,20 +33,39 @@ export class OpencodeSessionsProvider implements IProviderSessions {
26
33
 
27
34
  const ts = raw.timestamp || new Date().toISOString();
28
35
  const baseId = raw.uuid || raw.id || generateMessageId('opencode');
36
+ const part = readObjectRecord(raw.part) || {};
37
+
38
+ // Modern shape: { type:"text", part:{ type:"text", text:"…" } }.
39
+ // The full text arrives in a single event (not token-by-token), but it
40
+ // can also legitimately be empty during a step_start preamble — we only
41
+ // emit when there's actual text. `stream_end` is emitted on step_finish
42
+ // (which is guaranteed at the end of every assistant turn), so we don't
43
+ // close the stream here.
44
+ if (raw.type === 'text') {
45
+ const text = typeof part.text === 'string' ? part.text : '';
46
+ if (!text) return [];
47
+ return [createNormalizedMessage({
48
+ id: baseId,
49
+ sessionId,
50
+ timestamp: ts,
51
+ provider: PROVIDER,
52
+ kind: 'stream_delta',
53
+ content: text,
54
+ })];
55
+ }
29
56
 
57
+ // Legacy fallback for older builds that still emit `{ type:"message", role:"assistant", content }`.
30
58
  if (raw.type === 'message' && raw.role === 'assistant') {
31
- const content = raw.content || '';
32
- const messages: NormalizedMessage[] = [];
33
- if (content) {
34
- messages.push(createNormalizedMessage({
35
- id: baseId,
36
- sessionId,
37
- timestamp: ts,
38
- provider: PROVIDER,
39
- kind: 'stream_delta',
40
- content,
41
- }));
42
- }
59
+ const content = typeof raw.content === 'string' ? raw.content : '';
60
+ if (!content) return [];
61
+ const messages: NormalizedMessage[] = [createNormalizedMessage({
62
+ id: baseId,
63
+ sessionId,
64
+ timestamp: ts,
65
+ provider: PROVIDER,
66
+ kind: 'stream_delta',
67
+ content,
68
+ })];
43
69
  if (raw.delta !== true) {
44
70
  messages.push(createNormalizedMessage({
45
71
  sessionId,
@@ -52,32 +78,39 @@ export class OpencodeSessionsProvider implements IProviderSessions {
52
78
  }
53
79
 
54
80
  if (raw.type === 'tool_use' || raw.type === 'tool-use') {
81
+ const state = readObjectRecord(part.state) || {};
55
82
  return [createNormalizedMessage({
56
83
  id: baseId,
57
84
  sessionId,
58
85
  timestamp: ts,
59
86
  provider: PROVIDER,
60
87
  kind: 'tool_use',
61
- toolName: raw.tool_name || raw.name,
62
- toolInput: raw.parameters || raw.input || {},
63
- toolId: raw.tool_id || raw.id || baseId,
88
+ toolName: String(part.tool || raw.tool_name || raw.name || ''),
89
+ toolInput: state.input || part.input || raw.parameters || raw.input || {},
90
+ toolId: String(part.callID || part.id || raw.tool_id || raw.id || baseId),
64
91
  })];
65
92
  }
66
93
 
67
94
  if (raw.type === 'tool_result' || raw.type === 'tool-result') {
95
+ const state = readObjectRecord(part.state) || {};
96
+ const status = (state.status || raw.status) as string | undefined;
97
+ const output = state.output ?? part.output ?? raw.output;
68
98
  return [createNormalizedMessage({
69
99
  id: baseId,
70
100
  sessionId,
71
101
  timestamp: ts,
72
102
  provider: PROVIDER,
73
103
  kind: 'tool_result',
74
- toolId: raw.tool_id || raw.toolCallId || '',
75
- content: raw.output === undefined ? '' : String(raw.output),
76
- isError: raw.status === 'error' || Boolean(raw.isError),
104
+ toolId: String(part.callID || part.id || raw.tool_id || raw.toolCallId || ''),
105
+ content: output === undefined || output === null ? '' : String(output),
106
+ isError: status === 'error' || Boolean(raw.isError),
77
107
  })];
78
108
  }
79
109
 
80
- if (raw.type === 'result') {
110
+ // OpenCode signals end-of-turn with `step_finish` (run mode never emits
111
+ // a top-level `result`). Emit stream_end so the UI clears its
112
+ // "Processing…" state.
113
+ if (raw.type === 'step_finish' || raw.type === 'result') {
81
114
  return [createNormalizedMessage({
82
115
  sessionId,
83
116
  timestamp: ts,
@@ -86,6 +119,12 @@ export class OpencodeSessionsProvider implements IProviderSessions {
86
119
  })];
87
120
  }
88
121
 
122
+ // step_start carries the session ID but no user-visible content — we
123
+ // capture the session ID elsewhere (response handler) and drop the event.
124
+ if (raw.type === 'step_start') {
125
+ return [];
126
+ }
127
+
89
128
  if (raw.type === 'error') {
90
129
  // OpenCode `--format json` emits errors as
91
130
  // { type:"error", error:{ name, data:{ message, statusCode?, isRetryable? } } }
@@ -20,6 +20,53 @@ export class QwenSessionsProvider implements IProviderSessions {
20
20
  const ts = raw.timestamp || new Date().toISOString();
21
21
  const baseId = raw.uuid || generateMessageId('qwen');
22
22
 
23
+ // Qwen Code stream-json (verified from `qwen --output-format stream-json --yolo`):
24
+ // { type:"system", subtype:"init", session_id, ... }
25
+ // { type:"assistant", uuid, session_id, message:{ role:"assistant", content:[{type:"text", text:"…"}], usage:{...} } }
26
+ // { type:"result", subtype:"success", result:"…final…", usage:{...} }
27
+ // The previous matcher (`type==='message' && role==='assistant'`) checked
28
+ // a shape Qwen has never emitted — every chat returned empty messages,
29
+ // including API-error messages like "[API Error: 401 invalid access token
30
+ // or token expired]" which then landed in the void instead of reaching
31
+ // the user. Match the real shape.
32
+ if (raw.type === 'assistant' && raw.message && typeof raw.message === 'object') {
33
+ const msg = raw.message as Record<string, unknown>;
34
+ const parts = Array.isArray(msg.content) ? (msg.content as Record<string, unknown>[]) : [];
35
+ const text = parts
36
+ .map((p) => (typeof p?.text === 'string' ? p.text : ''))
37
+ .filter(Boolean)
38
+ .join('');
39
+ const messages: NormalizedMessage[] = [];
40
+ if (text) {
41
+ messages.push(createNormalizedMessage({
42
+ id: baseId,
43
+ sessionId,
44
+ timestamp: ts,
45
+ provider: PROVIDER,
46
+ kind: 'stream_delta',
47
+ content: text,
48
+ }));
49
+ }
50
+ // Surface tool_use parts inline so the agent route can see them too.
51
+ for (const part of parts) {
52
+ if (part?.type === 'tool_use') {
53
+ messages.push(createNormalizedMessage({
54
+ id: typeof part.id === 'string' ? part.id : generateMessageId('qwen'),
55
+ sessionId,
56
+ timestamp: ts,
57
+ provider: PROVIDER,
58
+ kind: 'tool_use',
59
+ toolName: typeof part.name === 'string' ? part.name : '',
60
+ toolInput: (part.input as AnyRecord) || {},
61
+ toolId: typeof part.id === 'string' ? part.id : '',
62
+ }));
63
+ }
64
+ }
65
+ return messages;
66
+ }
67
+
68
+ // Legacy `type:"message"` shape — kept so any persisted history that
69
+ // pre-dates the stream-json migration still parses cleanly.
23
70
  if (raw.type === 'message' && raw.role === 'assistant') {
24
71
  const content = raw.content || '';
25
72
  const messages: NormalizedMessage[] = [];
@@ -85,6 +85,31 @@ const PROVIDER_INSTALL_COMMANDS: Record<LLMProvider, string | null> = {
85
85
  cursor: null,
86
86
  };
87
87
 
88
+ /**
89
+ * Per-provider manual install hints, surfaced when `/install` is called
90
+ * for a provider Pixcode can't sandbox-install (anything not on npm).
91
+ * Each entry includes platform-specific commands so the UI can show the
92
+ * right one for the user's host. Cursor is the only provider in this
93
+ * bucket today — it ships via curl|bash on POSIX and a downloadable
94
+ * installer on Windows. We deliberately don't pipe-to-bash from Pixcode,
95
+ * so the user runs it themselves.
96
+ */
97
+ const PROVIDER_MANUAL_INSTALL: Partial<Record<LLMProvider, {
98
+ docsUrl: string;
99
+ steps: { platform: 'macos' | 'linux' | 'windows'; command: string }[];
100
+ note: string;
101
+ }>> = {
102
+ cursor: {
103
+ docsUrl: 'https://docs.cursor.com/en/cli/installation',
104
+ steps: [
105
+ { platform: 'macos', command: 'curl https://cursor.com/install -fsS | bash' },
106
+ { platform: 'linux', command: 'curl https://cursor.com/install -fsS | bash' },
107
+ { platform: 'windows', command: 'iwr https://cursor.com/install.ps1 -useb | iex' },
108
+ ],
109
+ note: 'Cursor ships outside npm — run the command for your platform in a separate terminal, then click "Refresh" on this page once the binary is on PATH.',
110
+ },
111
+ };
112
+
88
113
  const router = express.Router();
89
114
 
90
115
  const readPathParam = (value: unknown, name: string): string => {
@@ -450,10 +475,18 @@ router.post(
450
475
  const packageName = PROVIDER_INSTALL_PACKAGES[parsed];
451
476
  const installCmd = PROVIDER_INSTALL_COMMANDS[parsed];
452
477
  if (!packageName || !installCmd) {
453
- throw new AppError(
454
- `${parsed} cannot be installed automaticallyplease follow the documented install steps.`,
455
- { code: 'PROVIDER_NOT_AUTO_INSTALLABLE', statusCode: 400 },
456
- );
478
+ const manual = PROVIDER_MANUAL_INSTALL[parsed];
479
+ // Don't 4xx on thisthe call ISN'T malformed, the provider is
480
+ // simply not npm-installable. Return 200 with a `manual` payload
481
+ // the UI can present as instructions instead of "install failed".
482
+ res.json(createApiSuccessResponse({
483
+ provider: parsed,
484
+ manual: manual || null,
485
+ message: manual
486
+ ? `${parsed} ships outside npm. Run the platform-specific command, then refresh.`
487
+ : `${parsed} cannot be installed automatically — see the provider's documentation.`,
488
+ }));
489
+ return;
457
490
  }
458
491
 
459
492
  const job = createInstallJob({ provider: parsed, installCmd, packageName });
@@ -50,13 +50,34 @@ function mapPermissionModeToArgs(permissionMode, skipPermissions) {
50
50
  return { agent: 'build', dangerously: false };
51
51
  }
52
52
 
53
+ // Pixcode stores OpenCode permissions as two flat lists (`allowPatterns`,
54
+ // `denyPatterns`) in `opencode-settings` localStorage; the backend collapses
55
+ // them into the JSON shape OpenCode reads from `opencode.json`. We pass the
56
+ // merged config inline via `OPENCODE_CONFIG_CONTENT` (a documented OpenCode
57
+ // env var) instead of writing to disk — that way Pixcode's intent doesn't
58
+ // pollute the user's project tree, and project-local opencode.json overrides
59
+ // still take precedence per OpenCode's own precedence rules.
60
+ function buildOpencodePermissionConfig(settings) {
61
+ const allow = Array.isArray(settings?.allowPatterns) ? settings.allowPatterns.filter(Boolean) : [];
62
+ const deny = Array.isArray(settings?.denyPatterns) ? settings.denyPatterns.filter(Boolean) : [];
63
+ if (!allow.length && !deny.length) return null;
64
+
65
+ const bash = {};
66
+ // Deny rules win when patterns collide — apply allow first so deny
67
+ // overwrites duplicates.
68
+ for (const pattern of allow) bash[pattern] = 'allow';
69
+ for (const pattern of deny) bash[pattern] = 'deny';
70
+
71
+ return { permission: { bash } };
72
+ }
73
+
53
74
  async function spawnOpencode(command, options = {}, ws) {
54
75
  const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary, agent: agentOverride } = options;
55
76
  let capturedSessionId = sessionId;
56
77
  let sessionCreatedSent = false;
57
78
  let assistantBlocks = [];
58
79
 
59
- const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false };
80
+ const settings = toolsSettings || { allowPatterns: [], denyPatterns: [], skipPermissions: false };
60
81
 
61
82
  const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
62
83
  const args = ['run', '--format', 'json'];
@@ -73,7 +94,10 @@ async function spawnOpencode(command, options = {}, ws) {
73
94
  if (sessionId) {
74
95
  const session = sessionManager.getSession(sessionId);
75
96
  const cliId = session?.cliSessionId || sessionId;
76
- if (cliId && safeSessionIdPattern.test(cliId)) {
97
+ // OpenCode CLI requires session IDs to start with 'ses' (e.g. ses_22d6…).
98
+ // Pixcode's internal IDs (opencode_xxxxx) must NOT be passed to -s,
99
+ // otherwise the CLI exits with "Invalid session ID: must start with 'ses'".
100
+ if (cliId && safeSessionIdPattern.test(cliId) && cliId.startsWith('ses')) {
77
101
  args.push('-s', cliId);
78
102
  }
79
103
  }
@@ -135,6 +159,21 @@ async function spawnOpencode(command, options = {}, ws) {
135
159
 
136
160
  const spawnEnv = await buildSpawnEnv('opencode');
137
161
 
162
+ // Inject Pixcode's permission allow/deny patterns as inline OpenCode config.
163
+ // Skipped when `--dangerously-skip-permissions` is on (the flag overrides
164
+ // the config anyway) and when both lists are empty (avoid clobbering a
165
+ // project-local opencode.json the user has hand-tuned).
166
+ if (!dangerously) {
167
+ const permConfig = buildOpencodePermissionConfig(settings);
168
+ if (permConfig) {
169
+ try {
170
+ spawnEnv.OPENCODE_CONFIG_CONTENT = JSON.stringify(permConfig);
171
+ } catch (error) {
172
+ console.warn('[opencode] Failed to serialize permission config:', error?.message || error);
173
+ }
174
+ }
175
+ }
176
+
138
177
  return new Promise((resolve, reject) => {
139
178
  const opencodeProcess = spawnFunction(spawnCmd, spawnArgs, {
140
179
  cwd: workingDir,
@@ -1,10 +1,22 @@
1
1
  // OpenCode Response Handler — `opencode run --format json` parser.
2
2
  //
3
- // The JSON format streams one event per line (NDJSON). Event shapes follow
4
- // the OpenAPI contract exposed by `opencode serve`. We treat the stream
5
- // permissively: lines that don't parse as JSON are passed through as plain
6
- // text deltas (covers OpenCode's pre-stream banner output and any debug
7
- // noise the CLI emits to stdout).
3
+ // The JSON format streams one event per line (NDJSON). Verified shapes
4
+ // (opencode-ai 0.x, see opencode.ai/docs/cli):
5
+ //
6
+ // { type:"step_start", timestamp, sessionID, part:{ type:"step-start", id, messageID, sessionID } }
7
+ // { type:"text", timestamp, sessionID, part:{ type:"text", id, messageID, sessionID, text:"…", time:{…}, metadata:{…} } }
8
+ // { type:"tool_use", timestamp, sessionID, part:{ type:"tool-use", callID, tool, state:{ input } } }
9
+ // { type:"tool_result", timestamp, sessionID, part:{ type:"tool-result", callID, state:{ output, status } } }
10
+ // { type:"step_finish", timestamp, sessionID, part:{ type:"step-finish", reason, tokens, cost } }
11
+ // { type:"error", timestamp, sessionID, error:{ name, data:{ message, statusCode? } } }
12
+ //
13
+ // Important: field names are camelCase — `sessionID`, `callID`, `messageID`.
14
+ // Earlier integration code assumed snake_case (`session_id`, `tool_id`) which
15
+ // is wrong; nothing in `opencode run --format json` uses snake_case.
16
+ //
17
+ // Lines that don't parse as JSON are surfaced as plain text deltas (covers
18
+ // the CLI's pre-stream banner output, "Shell cwd was reset to …" notices on
19
+ // Windows, and any debug noise).
8
20
  import { sessionsService } from './modules/providers/services/sessions.service.js';
9
21
 
10
22
  class OpencodeResponseHandler {
@@ -15,6 +27,7 @@ class OpencodeResponseHandler {
15
27
  this.onInit = options.onInit || null;
16
28
  this.onToolUse = options.onToolUse || null;
17
29
  this.onToolResult = options.onToolResult || null;
30
+ this.capturedCliSessionId = null;
18
31
  }
19
32
 
20
33
  processData(data) {
@@ -40,37 +53,31 @@ class OpencodeResponseHandler {
40
53
  handleEvent(event) {
41
54
  const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
42
55
 
43
- if (event.type === 'init' || event.type === 'session.start') {
44
- if (this.onInit) this.onInit(event);
45
- return;
56
+ // Capture OpenCode's real session ID (e.g. `ses_22d6…`) the first time we
57
+ // see one. Every event carries `sessionID` at the top level — we don't
58
+ // wait for an `init`/`session.start` event because OpenCode `run --format
59
+ // json` never emits those (the first event is always `step_start`).
60
+ if (!this.capturedCliSessionId && typeof event.sessionID === 'string' && event.sessionID) {
61
+ this.capturedCliSessionId = event.sessionID;
62
+ if (this.onInit) this.onInit({ session_id: event.sessionID, sessionID: event.sessionID });
46
63
  }
47
64
 
48
- // OpenCode emits assistant text as either a top-level `text` event
49
- // (current `--format json` shape: `{ type: "text", part: { text } }`)
50
- // or as `message`/`part` legacy shapes from earlier builds. Cover all
51
- // three so we don't drop tokens on a CLI version we haven't matched.
52
- if (event.type === 'text' && event.part && typeof event.part.text === 'string') {
53
- if (this.onContentFragment && event.part.text) this.onContentFragment(event.part.text);
54
- } else if (event.type === 'message' && event.role === 'assistant') {
55
- const content = event.content || event.text || '';
56
- if (this.onContentFragment && content) this.onContentFragment(content);
57
- } else if (event.type === 'part' && event.part_type === 'text') {
58
- const content = event.text || event.content || '';
59
- if (this.onContentFragment && content) this.onContentFragment(content);
65
+ const part = event.part && typeof event.part === 'object' ? event.part : null;
66
+
67
+ if (event.type === 'text' && part && typeof part.text === 'string') {
68
+ if (this.onContentFragment && part.text) this.onContentFragment(part.text);
60
69
  } else if (event.type === 'tool_use' || event.type === 'tool-use' || event.type === 'tool.start') {
61
- // Tool-use shape on `--format json`: `{ type:"tool_use", part:{ callID, tool, state:{ input } } }`
62
- const part = event.part || event;
70
+ const state = part?.state && typeof part.state === 'object' ? part.state : {};
63
71
  if (this.onToolUse) this.onToolUse({
64
- tool_id: part.callID || part.id || event.tool_id,
65
- tool_name: part.tool || part.name || event.tool_name,
66
- parameters: part.state?.input || part.input || event.parameters || {},
72
+ tool_id: part?.callID || part?.id || event.tool_id || '',
73
+ tool_name: part?.tool || part?.name || event.tool_name || '',
74
+ parameters: state.input || part?.input || event.parameters || {},
67
75
  });
68
76
  } else if (event.type === 'tool_result' || event.type === 'tool-result' || event.type === 'tool.end') {
69
- const part = event.part || event;
70
- const state = part.state || {};
77
+ const state = part?.state && typeof part.state === 'object' ? part.state : {};
71
78
  if (this.onToolResult) this.onToolResult({
72
- tool_id: part.callID || part.id || event.tool_id,
73
- output: state.output ?? part.output ?? event.output ?? event.result ?? '',
79
+ tool_id: part?.callID || part?.id || event.tool_id || '',
80
+ output: state.output ?? part?.output ?? event.output ?? event.result ?? '',
74
81
  status: state.status || event.status || (event.isError ? 'error' : 'ok'),
75
82
  });
76
83
  }