@pixelbyte-software/pixcode 1.42.4 → 1.43.0

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 (52) hide show
  1. package/dist/assets/index-B-_FofJ_.css +32 -0
  2. package/dist/assets/{index-cTGs3Dvx.js → index-CDKI7Ucy.js} +170 -170
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/index.js +3 -0
  5. package/dist-server/server/index.js.map +1 -1
  6. package/dist-server/server/modules/orchestration/index.js +2 -0
  7. package/dist-server/server/modules/orchestration/index.js.map +1 -1
  8. package/dist-server/server/modules/orchestration/workflows/approval-queue.js +72 -0
  9. package/dist-server/server/modules/orchestration/workflows/approval-queue.js.map +1 -0
  10. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +25 -0
  11. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  12. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js +242 -0
  13. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js.map +1 -0
  14. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js +21 -0
  15. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js.map +1 -1
  16. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +121 -0
  17. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -1
  18. package/dist-server/server/routes/public-api.js +7 -1
  19. package/dist-server/server/routes/public-api.js.map +1 -1
  20. package/dist-server/server/routes/remote.js +18 -0
  21. package/dist-server/server/routes/remote.js.map +1 -1
  22. package/dist-server/server/routes/webhooks.js +53 -0
  23. package/dist-server/server/routes/webhooks.js.map +1 -0
  24. package/dist-server/server/services/control-room.js +89 -0
  25. package/dist-server/server/services/control-room.js.map +1 -0
  26. package/dist-server/server/services/public-api-manifest.js +96 -0
  27. package/dist-server/server/services/public-api-manifest.js.map +1 -1
  28. package/dist-server/server/services/telegram/control-center.js +110 -0
  29. package/dist-server/server/services/telegram/control-center.js.map +1 -1
  30. package/dist-server/server/services/telegram/translations.js +24 -2
  31. package/dist-server/server/services/telegram/translations.js.map +1 -1
  32. package/dist-server/server/services/webhooks.js +198 -0
  33. package/dist-server/server/services/webhooks.js.map +1 -0
  34. package/package.json +1 -1
  35. package/scripts/smoke/v143-remote-control.mjs +76 -0
  36. package/scripts/smoke/workflow-templates.mjs +43 -0
  37. package/server/index.js +4 -0
  38. package/server/modules/orchestration/index.ts +14 -0
  39. package/server/modules/orchestration/workflows/approval-queue.ts +106 -0
  40. package/server/modules/orchestration/workflows/workflow-runner.ts +25 -0
  41. package/server/modules/orchestration/workflows/workflow-templates.ts +272 -0
  42. package/server/modules/orchestration/workflows/workflow-trace.ts +22 -0
  43. package/server/modules/orchestration/workflows/workflow.routes.ts +139 -0
  44. package/server/routes/public-api.js +14 -1
  45. package/server/routes/remote.js +22 -0
  46. package/server/routes/webhooks.js +63 -0
  47. package/server/services/control-room.js +102 -0
  48. package/server/services/public-api-manifest.js +98 -0
  49. package/server/services/telegram/control-center.js +113 -0
  50. package/server/services/telegram/translations.js +24 -2
  51. package/server/services/webhooks.js +216 -0
  52. package/dist/assets/index-CHa1760s.css +0 -32
@@ -6,9 +6,19 @@ import {
6
6
  type WorkflowReplayScope,
7
7
  buildWorkflowReplayPlan,
8
8
  } from '@/modules/orchestration/workflows/workflow-replay.js';
9
+ import {
10
+ applyWorkflowTemplateToMetadata,
11
+ builtInWorkflowTemplates,
12
+ getWorkflowTemplate,
13
+ } from '@/modules/orchestration/workflows/workflow-templates.js';
9
14
  import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
10
15
  import { buildWorkflowTrace } from '@/modules/orchestration/workflows/workflow-trace.js';
11
16
  import { findPixcodeAppRoot } from '@/modules/orchestration/workflows/workspace-target.js';
17
+ import {
18
+ listPendingApprovals,
19
+ resolvePermissionApproval,
20
+ type ApprovalDecisionSource,
21
+ } from '@/modules/orchestration/workflows/approval-queue.js';
12
22
  import {
13
23
  DEFAULT_PERMISSION_POLICY,
14
24
  PERMISSION_CAPABILITIES,
@@ -17,6 +27,7 @@ import {
17
27
  evaluatePermissionRequest,
18
28
  normalizePermissionPolicy,
19
29
  } from '@/modules/orchestration/security/permission-policy.js';
30
+ import { dispatchWebhookEvent } from '@/services/webhooks.js';
20
31
 
21
32
  const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
22
33
 
@@ -130,6 +141,12 @@ export function createWorkflowRouter(): Router {
130
141
  res.json({ workflows: workflowStore.listWorkflows() });
131
142
  });
132
143
 
144
+ router.get('/workflows/templates', (_req, res) => {
145
+ res.json({
146
+ templates: builtInWorkflowTemplates,
147
+ });
148
+ });
149
+
133
150
  router.get('/workflows/context', (_req, res) => {
134
151
  res.json({
135
152
  appRoot: findPixcodeAppRoot(),
@@ -243,6 +260,55 @@ export function createWorkflowRouter(): Router {
243
260
  });
244
261
  });
245
262
 
263
+ router.get('/workflows/approvals', (req, res) => {
264
+ res.json({
265
+ pendingApprovals: listPendingApprovals({
266
+ projectId: readOptionalString(req.query.projectId),
267
+ includeResolved: readBooleanFlag(req.query.includeResolved),
268
+ }),
269
+ });
270
+ });
271
+
272
+ router.post('/workflows/approvals/:approvalId', (req, res) => {
273
+ const allow = req.body?.allow === true;
274
+ const deny = req.body?.allow === false;
275
+ if (!allow && !deny) {
276
+ res.status(400).json({
277
+ error: {
278
+ code: 'PERMISSION_DECISION_REQUIRED',
279
+ message: 'Approval queue decisions require allow=true or allow=false.',
280
+ },
281
+ });
282
+ return;
283
+ }
284
+
285
+ const source = ['ui', 'telegram', 'api'].includes(req.body?.source)
286
+ ? req.body.source as ApprovalDecisionSource
287
+ : 'api';
288
+ const result = resolvePermissionApproval({
289
+ approvalId: req.params.approvalId,
290
+ allow,
291
+ source,
292
+ resolvedBy: readRequestUserId(req),
293
+ message: readOptionalString(req.body?.message),
294
+ });
295
+ if (!result) {
296
+ res.status(404).json({ error: { code: 'APPROVAL_NOT_FOUND', message: req.params.approvalId } });
297
+ return;
298
+ }
299
+
300
+ dispatchWebhookEvent({
301
+ type: 'approval.resolved',
302
+ payload: {
303
+ approvalId: req.params.approvalId,
304
+ allow,
305
+ source,
306
+ runId: result.runId,
307
+ },
308
+ });
309
+ res.json(result);
310
+ });
311
+
246
312
  router.post('/workflows/runs/:runId/permission-approvals/:requestId', (req, res) => {
247
313
  const run = workflowStore.getRun(req.params.runId);
248
314
  if (!run) {
@@ -274,6 +340,15 @@ export function createWorkflowRouter(): Router {
274
340
  }
275
341
 
276
342
  workflowStore.setRun(run);
343
+ dispatchWebhookEvent({
344
+ type: 'approval.resolved',
345
+ payload: {
346
+ approvalId: req.params.requestId,
347
+ allow,
348
+ source: 'ui',
349
+ runId: run.id,
350
+ },
351
+ });
277
352
  res.json({
278
353
  runId: run.id,
279
354
  pendingApprovals: readRunArray(run, 'pendingPermissionApprovals')
@@ -348,6 +423,14 @@ export function createWorkflowRouter(): Router {
348
423
  },
349
424
  },
350
425
  );
426
+ dispatchWebhookEvent({
427
+ type: 'run.started',
428
+ payload: {
429
+ runId: replayRun.id,
430
+ workflowId: replayRun.workflowId,
431
+ replayOf: run.id,
432
+ },
433
+ });
351
434
  res.status(202).json({
352
435
  run: replayRun,
353
436
  replayPlan,
@@ -413,6 +496,13 @@ export function createWorkflowRouter(): Router {
413
496
  res.status(404).json({ error: { code: 'RUN_NOT_FOUND', message: req.params.runId } });
414
497
  return;
415
498
  }
499
+ dispatchWebhookEvent({
500
+ type: 'run.canceled',
501
+ payload: {
502
+ runId: run.id,
503
+ workflowId: run.workflowId,
504
+ },
505
+ });
416
506
  res.json(run);
417
507
  });
418
508
 
@@ -432,6 +522,14 @@ export function createWorkflowRouter(): Router {
432
522
  workflowName: workflow.name,
433
523
  },
434
524
  );
525
+ dispatchWebhookEvent({
526
+ type: 'run.started',
527
+ payload: {
528
+ runId: run.id,
529
+ workflowId: run.workflowId,
530
+ workflowName: workflow.name,
531
+ },
532
+ });
435
533
  res.status(202).json(run);
436
534
  } catch (error) {
437
535
  res.status(400).json({
@@ -443,5 +541,46 @@ export function createWorkflowRouter(): Router {
443
541
  }
444
542
  });
445
543
 
544
+ router.post('/workflows/templates/:templateId/runs', (req, res) => {
545
+ const template = getWorkflowTemplate(req.params.templateId);
546
+ if (!template) {
547
+ res.status(404).json({ error: { code: 'WORKFLOW_TEMPLATE_NOT_FOUND', message: req.params.templateId } });
548
+ return;
549
+ }
550
+ const workflow = workflowStore.getWorkflow(template.workflowId);
551
+ if (!workflow) {
552
+ res.status(404).json({ error: { code: 'WORKFLOW_NOT_FOUND', message: template.workflowId } });
553
+ return;
554
+ }
555
+
556
+ try {
557
+ const run = workflowRunner.start(
558
+ workflow,
559
+ typeof req.body?.input === 'string' ? req.body.input : '',
560
+ {
561
+ ...applyWorkflowTemplateToMetadata(template, readMetadata(req.body)),
562
+ userId: readRequestUserId(req),
563
+ workflowName: workflow.name,
564
+ },
565
+ );
566
+ dispatchWebhookEvent({
567
+ type: 'run.started',
568
+ payload: {
569
+ runId: run.id,
570
+ workflowId: run.workflowId,
571
+ templateId: template.id,
572
+ },
573
+ });
574
+ res.status(202).json(run);
575
+ } catch (error) {
576
+ res.status(400).json({
577
+ error: {
578
+ code: 'WORKFLOW_TEMPLATE_START_FAILED',
579
+ message: error instanceof Error ? error.message : String(error),
580
+ },
581
+ });
582
+ }
583
+ });
584
+
446
585
  return router;
447
586
  }
@@ -1,6 +1,11 @@
1
1
  import express from 'express';
2
2
 
3
- import { buildOpenApiFragment, buildPublicApiManifest } from '../services/public-api-manifest.js';
3
+ import {
4
+ buildCurlCookbook,
5
+ buildOpenApiFragment,
6
+ buildPublicApiManifest,
7
+ buildTypeScriptSdkStarter,
8
+ } from '../services/public-api-manifest.js';
4
9
 
5
10
  const router = express.Router();
6
11
 
@@ -18,4 +23,12 @@ router.get('/openapi', (req, res) => {
18
23
  res.json(buildOpenApiFragment({ baseUrl: requestBaseUrl(req) }));
19
24
  });
20
25
 
26
+ router.get('/sdk/typescript', (req, res) => {
27
+ res.type('text/typescript').send(buildTypeScriptSdkStarter({ baseUrl: requestBaseUrl(req) }));
28
+ });
29
+
30
+ router.get('/cookbook', (req, res) => {
31
+ res.json(buildCurlCookbook({ baseUrl: requestBaseUrl(req) }));
32
+ });
33
+
21
34
  export default router;
@@ -5,6 +5,10 @@ import {
5
5
  getPublicRemoteConnectionConfig,
6
6
  saveRemoteConnectionConfig,
7
7
  } from '../services/remote-connection.js';
8
+ import {
9
+ buildControlRoomSnapshot,
10
+ buildMobileConsoleLayout,
11
+ } from '../services/control-room.js';
8
12
 
9
13
  const router = express.Router();
10
14
 
@@ -30,4 +34,22 @@ router.post('/check', async (req, res) => {
30
34
  }
31
35
  });
32
36
 
37
+ router.get('/control-room', async (_req, res) => {
38
+ try {
39
+ res.json({
40
+ success: true,
41
+ controlRoom: await buildControlRoomSnapshot(),
42
+ });
43
+ } catch (error) {
44
+ res.status(500).json({ success: false, error: error.message });
45
+ }
46
+ });
47
+
48
+ router.get('/console-layout', (_req, res) => {
49
+ res.json({
50
+ success: true,
51
+ layout: buildMobileConsoleLayout(),
52
+ });
53
+ });
54
+
33
55
  export default router;
@@ -0,0 +1,63 @@
1
+ import express from 'express';
2
+
3
+ import {
4
+ PIXCODE_WEBHOOK_EVENT_TYPES,
5
+ deleteWebhook,
6
+ deliverWebhookEvent,
7
+ listWebhooks,
8
+ upsertWebhook,
9
+ } from '../services/webhooks.js';
10
+
11
+ const router = express.Router();
12
+
13
+ router.get('/', (_req, res) => {
14
+ res.json({
15
+ success: true,
16
+ eventTypes: PIXCODE_WEBHOOK_EVENT_TYPES,
17
+ webhooks: listWebhooks(),
18
+ });
19
+ });
20
+
21
+ router.post('/', (req, res) => {
22
+ try {
23
+ const webhook = upsertWebhook(req.body || {});
24
+ res.status(201).json({ success: true, webhook });
25
+ } catch (error) {
26
+ res.status(400).json({ success: false, error: error.message });
27
+ }
28
+ });
29
+
30
+ router.patch('/:id', (req, res) => {
31
+ try {
32
+ const webhook = upsertWebhook({ ...(req.body || {}), id: req.params.id });
33
+ res.json({ success: true, webhook });
34
+ } catch (error) {
35
+ res.status(400).json({ success: false, error: error.message });
36
+ }
37
+ });
38
+
39
+ router.delete('/:id', (req, res) => {
40
+ if (!deleteWebhook(req.params.id)) {
41
+ res.status(404).json({ success: false, error: 'Webhook not found.' });
42
+ return;
43
+ }
44
+ res.json({ success: true });
45
+ });
46
+
47
+ router.post('/test', async (req, res) => {
48
+ try {
49
+ const result = await deliverWebhookEvent({
50
+ type: req.body?.type || 'run.completed',
51
+ payload: {
52
+ test: true,
53
+ message: 'Pixcode webhook test delivery',
54
+ sentBy: req.user?.id ?? null,
55
+ },
56
+ });
57
+ res.json({ success: true, result });
58
+ } catch (error) {
59
+ res.status(500).json({ success: false, error: error.message });
60
+ }
61
+ });
62
+
63
+ export default router;
@@ -0,0 +1,102 @@
1
+ import { listPendingApprovals, workflowStore } from '../modules/orchestration/index.js';
2
+ import { getProjects } from '../projects.js';
3
+
4
+ import { listWebhooks } from './webhooks.js';
5
+
6
+ const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
7
+
8
+ function projectPath(project) {
9
+ return project.fullPath || project.path || '';
10
+ }
11
+
12
+ function runBelongsToProject(run, project) {
13
+ const projectId = run.metadata?.projectId;
14
+ const path = run.metadata?.projectPath;
15
+ return projectId === project.name || path === projectPath(project);
16
+ }
17
+
18
+ function countSessions(project) {
19
+ return [
20
+ project.sessions,
21
+ project.codexSessions,
22
+ project.cursorSessions,
23
+ project.geminiSessions,
24
+ project.qwenSessions,
25
+ project.opencodeSessions,
26
+ ].reduce((total, sessions) => total + (Array.isArray(sessions) ? sessions.length : 0), 0);
27
+ }
28
+
29
+ function summarizeProject(project, runs, approvals) {
30
+ const projectRuns = runs.filter((run) => runBelongsToProject(run, project));
31
+ const runningRuns = projectRuns.filter((run) => !TERMINAL_RUN_STATES.has(run.status));
32
+ const failedRuns = projectRuns.filter((run) => run.status === 'failed');
33
+ const projectApprovals = approvals.filter((approval) => (
34
+ approval.runId && projectRuns.some((run) => run.id === approval.runId)
35
+ ));
36
+
37
+ return {
38
+ id: project.name,
39
+ name: project.displayName || project.name,
40
+ path: projectPath(project),
41
+ sessionCount: countSessions(project),
42
+ activeRunCount: runningRuns.length,
43
+ failedRunCount: failedRuns.length,
44
+ pendingApprovalCount: projectApprovals.length,
45
+ latestRuns: projectRuns.slice(0, 4).map((run) => ({
46
+ id: run.id,
47
+ workflowId: run.workflowId,
48
+ status: run.status,
49
+ startedAt: run.startedAt,
50
+ finishedAt: run.finishedAt,
51
+ })),
52
+ };
53
+ }
54
+
55
+ export async function buildControlRoomSnapshot({ maxProjects = 4 } = {}) {
56
+ const projects = await getProjects();
57
+ const runs = workflowStore.listRuns();
58
+ const pendingApprovals = listPendingApprovals();
59
+ const webhooks = listWebhooks();
60
+ const projectCards = projects
61
+ .map((project) => summarizeProject(project, runs, pendingApprovals))
62
+ .sort((a, b) => (
63
+ b.pendingApprovalCount - a.pendingApprovalCount ||
64
+ b.activeRunCount - a.activeRunCount ||
65
+ b.failedRunCount - a.failedRunCount ||
66
+ a.name.localeCompare(b.name)
67
+ ))
68
+ .slice(0, maxProjects);
69
+
70
+ return {
71
+ protocol: 'pixcode.control-room.v1',
72
+ generatedAt: new Date().toISOString(),
73
+ maxProjects,
74
+ mobileFirst: true,
75
+ totals: {
76
+ projects: projects.length,
77
+ activeRuns: runs.filter((run) => !TERMINAL_RUN_STATES.has(run.status)).length,
78
+ failedRuns: runs.filter((run) => run.status === 'failed').length,
79
+ pendingApprovals: pendingApprovals.length,
80
+ webhooks: webhooks.length,
81
+ enabledWebhooks: webhooks.filter((webhook) => webhook.enabled).length,
82
+ },
83
+ projects: projectCards,
84
+ approvals: pendingApprovals.slice(0, 20),
85
+ webhooks,
86
+ };
87
+ }
88
+
89
+ export function buildMobileConsoleLayout() {
90
+ return {
91
+ protocol: 'pixcode.remote-console-layout.v1',
92
+ mobileFirst: true,
93
+ sections: [
94
+ { id: 'projects', title: 'Projects', priority: 1 },
95
+ { id: 'approvals', title: 'Approval queue', priority: 2 },
96
+ { id: 'runs', title: 'Runs', priority: 3 },
97
+ { id: 'webhooks', title: 'Webhooks', priority: 4 },
98
+ { id: 'api', title: 'API SDK', priority: 5 },
99
+ ],
100
+ maxVisibleProjects: 4,
101
+ };
102
+ }
@@ -13,6 +13,7 @@ const API_GROUPS = [
13
13
  { id: 'diagnostics', title: 'Diagnostics', basePath: '/api/diagnostics', scopes: ['diagnostics:read'] },
14
14
  { id: 'remote', title: 'Remote connection', basePath: '/api/remote', scopes: ['remote:read', 'remote:write'] },
15
15
  { id: 'telegram', title: 'Telegram control', basePath: '/api/telegram', scopes: ['telegram:read', 'telegram:write'] },
16
+ { id: 'webhooks', title: 'Outbound webhooks', basePath: '/api/webhooks', scopes: ['webhooks:read', 'webhooks:write'] },
16
17
  { id: 'plugins', title: 'Plugins and MCP tools', basePath: '/api/plugins', scopes: ['plugins:read', 'plugins:write'] },
17
18
  ];
18
19
 
@@ -48,6 +49,103 @@ export function buildPublicApiManifest({ baseUrl = '' } = {}) {
48
49
  title: 'Fetch diagnostics bundle',
49
50
  curl: `curl -H "X-API-Key: px_your_key" ${origin || 'http://127.0.0.1:3001'}/api/diagnostics/bundle`,
50
51
  },
52
+ {
53
+ title: 'Read the mobile remote control room',
54
+ curl: `curl -H "X-API-Key: px_your_key" ${origin || 'http://127.0.0.1:3001'}/api/remote/control-room`,
55
+ },
56
+ {
57
+ title: 'Register an outbound webhook',
58
+ curl: `curl -X POST -H "Content-Type: application/json" -H "X-API-Key: px_your_key" -d '{"name":"CI listener","url":"https://example.com/pixcode","events":["run.completed","approval.needed"]}' ${origin || 'http://127.0.0.1:3001'}/api/webhooks`,
59
+ },
60
+ ],
61
+ };
62
+ }
63
+
64
+ export function buildTypeScriptSdkStarter({ baseUrl = '' } = {}) {
65
+ const origin = String(baseUrl || 'http://127.0.0.1:3001').replace(/\/+$/, '');
66
+ return `export type PixcodeRun = {
67
+ id: string;
68
+ workflowId: string;
69
+ status: 'queued' | 'running' | 'completed' | 'failed' | 'canceled';
70
+ };
71
+
72
+ export class PixcodeClient {
73
+ constructor(
74
+ private readonly apiKey: string,
75
+ private readonly baseUrl = '${origin}',
76
+ ) {}
77
+
78
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
79
+ const response = await fetch(new URL(path, this.baseUrl), {
80
+ ...init,
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'X-API-Key': this.apiKey,
84
+ ...(init.headers || {}),
85
+ },
86
+ });
87
+ if (!response.ok) throw new Error(\`Pixcode API \${response.status}: \${await response.text()}\`);
88
+ return response.json() as Promise<T>;
89
+ }
90
+
91
+ projects() {
92
+ return this.request<{ projects: unknown[] }>('/api/projects');
93
+ }
94
+
95
+ controlRoom() {
96
+ return this.request<{ success: true; controlRoom: unknown }>('/api/remote/control-room');
97
+ }
98
+
99
+ approvals() {
100
+ return this.request<{ pendingApprovals: unknown[] }>('/api/orchestration/workflows/approvals');
101
+ }
102
+
103
+ decideApproval(approvalId: string, allow: boolean) {
104
+ return this.request(\`/api/orchestration/workflows/approvals/\${encodeURIComponent(approvalId)}\`, {
105
+ method: 'POST',
106
+ body: JSON.stringify({ allow, source: 'api' }),
107
+ });
108
+ }
109
+
110
+ startWorkflow(workflowId: string, input: string, metadata: Record<string, unknown> = {}) {
111
+ return this.request<PixcodeRun>(\`/api/orchestration/workflows/\${encodeURIComponent(workflowId)}/runs\`, {
112
+ method: 'POST',
113
+ body: JSON.stringify({ input, metadata }),
114
+ });
115
+ }
116
+ }
117
+ `;
118
+ }
119
+
120
+ export function buildCurlCookbook({ baseUrl = '' } = {}) {
121
+ const origin = String(baseUrl || 'http://127.0.0.1:3001').replace(/\/+$/, '');
122
+ return {
123
+ title: 'Pixcode Public API Cookbook',
124
+ variables: {
125
+ PIXCODE_URL: origin,
126
+ PIXCODE_API_KEY: 'px_your_key',
127
+ },
128
+ examples: [
129
+ {
130
+ title: 'List projects',
131
+ command: `curl -H "X-API-Key: $PIXCODE_API_KEY" "$PIXCODE_URL/api/projects"`,
132
+ },
133
+ {
134
+ title: 'Read the mobile control room',
135
+ command: `curl -H "X-API-Key: $PIXCODE_API_KEY" "$PIXCODE_URL/api/remote/control-room"`,
136
+ },
137
+ {
138
+ title: 'List pending approvals',
139
+ command: `curl -H "X-API-Key: $PIXCODE_API_KEY" "$PIXCODE_URL/api/orchestration/workflows/approvals"`,
140
+ },
141
+ {
142
+ title: 'Approve a pending action',
143
+ command: `curl -X POST -H "Content-Type: application/json" -H "X-API-Key: $PIXCODE_API_KEY" -d '{"allow":true,"source":"api"}' "$PIXCODE_URL/api/orchestration/workflows/approvals/approval_id"`,
144
+ },
145
+ {
146
+ title: 'Create a webhook',
147
+ command: `curl -X POST -H "Content-Type: application/json" -H "X-API-Key: $PIXCODE_API_KEY" -d '{"name":"CI listener","url":"https://example.com/pixcode","events":["run.completed","run.failed","approval.needed"]}' "$PIXCODE_URL/api/webhooks"`,
148
+ },
51
149
  ],
52
150
  };
53
151
  }
@@ -37,6 +37,10 @@ const CONTROL_COMMANDS = new Set([
37
37
  '/workflows',
38
38
  '/orchestration',
39
39
  '/runs',
40
+ '/approvals',
41
+ '/controlroom',
42
+ '/control-room',
43
+ '/webhooks',
40
44
  '/sessions',
41
45
  '/newchat',
42
46
  '/tasks',
@@ -203,6 +207,8 @@ function mainMenuKeyboard(lang) {
203
207
  [button(t(lang, 'control.button.projects'), 'projects'), button(t(lang, 'control.button.provider'), 'providers')],
204
208
  [button(t(lang, 'control.button.models'), 'models'), button(t(lang, 'control.button.workflows'), 'workflows')],
205
209
  [button(t(lang, 'control.button.tasks'), 'tasks'), button(t(lang, 'control.button.runs'), 'runs')],
210
+ [button(t(lang, 'control.button.approvals'), 'approvals'), button(t(lang, 'control.button.controlRoom'), 'control_room')],
211
+ [button(t(lang, 'control.button.webhooks'), 'webhooks')],
206
212
  [button(t(lang, 'control.button.sessions'), 'sessions'), button(t(lang, 'control.button.newChat'), 'new_chat')],
207
213
  [button(t(lang, 'control.button.install'), 'install_menu'), button(t(lang, 'control.button.auth'), 'auth_menu')],
208
214
  [button(t(lang, 'control.button.settings'), 'settings')],
@@ -338,6 +344,82 @@ async function showRuns({ bot, chatId, link, editMessageId }) {
338
344
  });
339
345
  }
340
346
 
347
+ async function showApprovalQueue({ bot, chatId, link, editMessageId }) {
348
+ const lang = languageFor(link);
349
+ const data = await localApi(link.user_id, '/api/orchestration/workflows/approvals');
350
+ const approvals = Array.isArray(data?.pendingApprovals) ? data.pendingApprovals : [];
351
+ if (approvals.length === 0) {
352
+ await send(bot, chatId, t(lang, 'control.noApprovals'), { editMessageId });
353
+ return;
354
+ }
355
+
356
+ const keyboard = [];
357
+ const lines = approvals.slice(0, 8).map((approval, index) => {
358
+ const label = compact(approval.summary || approval.reason || approval.id, 70);
359
+ keyboard.push([
360
+ button(t(lang, 'control.button.approve'), 'approval_decide', { approvalId: approval.id, allow: true }),
361
+ button(t(lang, 'control.button.deny'), 'approval_decide', { approvalId: approval.id, allow: false }),
362
+ ]);
363
+ return `${index + 1}. ${label}\nRun: ${approval.runId}`;
364
+ });
365
+ keyboard.push([button(t(lang, 'control.button.refresh'), 'approvals'), button(t(lang, 'control.button.mainMenu'), 'menu')]);
366
+ await send(bot, chatId, `${t(lang, 'control.approvalQueue')}\n\n${lines.join('\n\n')}`, {
367
+ editMessageId,
368
+ reply_markup: { inline_keyboard: keyboard },
369
+ });
370
+ }
371
+
372
+ async function showControlRoom({ bot, chatId, link, editMessageId }) {
373
+ const lang = languageFor(link);
374
+ const data = await localApi(link.user_id, '/api/remote/control-room');
375
+ const snapshot = data?.controlRoom || data;
376
+ const projects = Array.isArray(snapshot?.projects) ? snapshot.projects : [];
377
+ const totals = snapshot?.totals || {};
378
+ const lines = projects.map((project, index) => [
379
+ `${index + 1}. ${compact(project.name || project.id, 44)}`,
380
+ `Runs: ${project.activeRunCount || 0} active / ${project.failedRunCount || 0} failed`,
381
+ `Approvals: ${project.pendingApprovalCount || 0}`,
382
+ ].join('\n'));
383
+ await send(bot, chatId, [
384
+ t(lang, 'control.controlRoomTitle'),
385
+ '',
386
+ `Projects: ${totals.projects || 0}`,
387
+ `Active runs: ${totals.activeRuns || 0}`,
388
+ `Pending approvals: ${totals.pendingApprovals || 0}`,
389
+ '',
390
+ lines.join('\n\n') || t(lang, 'control.noProjects'),
391
+ ].join('\n'), {
392
+ editMessageId,
393
+ reply_markup: {
394
+ inline_keyboard: [
395
+ [button(t(lang, 'control.button.approvals'), 'approvals'), button(t(lang, 'control.button.runs'), 'runs')],
396
+ [button(t(lang, 'control.button.mainMenu'), 'menu')],
397
+ ],
398
+ },
399
+ });
400
+ }
401
+
402
+ async function showWebhookMenu({ bot, chatId, link, editMessageId }) {
403
+ const lang = languageFor(link);
404
+ const data = await localApi(link.user_id, '/api/webhooks');
405
+ const webhooks = Array.isArray(data?.webhooks) ? data.webhooks : [];
406
+ const lines = webhooks.slice(0, 10).map((webhook, index) => (
407
+ `${index + 1}. ${webhook.enabled ? 'on' : 'off'} ${compact(webhook.name || webhook.url, 50)}\n${compact(webhook.events?.join(', ') || webhook.url, 90)}`
408
+ ));
409
+ await send(bot, chatId, [
410
+ t(lang, 'control.webhookTitle'),
411
+ '',
412
+ lines.join('\n\n') || t(lang, 'control.noWebhooks'),
413
+ ].join('\n'), {
414
+ editMessageId,
415
+ reply_markup: {
416
+ inline_keyboard: [
417
+ [button(t(lang, 'control.button.refresh'), 'webhooks'), button(t(lang, 'control.button.mainMenu'), 'menu')],
418
+ ],
419
+ },
420
+ });
421
+ }
422
+
341
423
  async function showSessions({ bot, chatId, link, editMessageId }) {
342
424
  const lang = languageFor(link);
343
425
  const state = getState(link.user_id);
@@ -783,6 +865,18 @@ async function handleCommand({ bot, chatId, link, text }) {
783
865
  await showRuns({ bot, chatId, link });
784
866
  return true;
785
867
  }
868
+ if (command === '/approvals') {
869
+ await showApprovalQueue({ bot, chatId, link });
870
+ return true;
871
+ }
872
+ if (command === '/controlroom' || command === '/control-room') {
873
+ await showControlRoom({ bot, chatId, link });
874
+ return true;
875
+ }
876
+ if (command === '/webhooks') {
877
+ await showWebhookMenu({ bot, chatId, link });
878
+ return true;
879
+ }
786
880
  if (command === '/sessions') {
787
881
  await showSessions({ bot, chatId, link });
788
882
  return true;
@@ -927,6 +1021,9 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
927
1021
  if (action === 'models_refresh') return showModelMenu({ bot, chatId, link, refresh: true, editMessageId });
928
1022
  if (action === 'workflows') return showWorkflowMenu({ bot, chatId, link, editMessageId });
929
1023
  if (action === 'runs') return showRuns({ bot, chatId, link, editMessageId });
1024
+ if (action === 'approvals') return showApprovalQueue({ bot, chatId, link, editMessageId });
1025
+ if (action === 'control_room') return showControlRoom({ bot, chatId, link, editMessageId });
1026
+ if (action === 'webhooks') return showWebhookMenu({ bot, chatId, link, editMessageId });
930
1027
  if (action === 'sessions') return showSessions({ bot, chatId, link, editMessageId });
931
1028
  if (action === 'new_chat') return startNewChat({ bot, chatId, link, editMessageId });
932
1029
  if (action === 'tasks') return showTaskMasterTasks({ bot, chatId, link, editMessageId });
@@ -989,6 +1086,22 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
989
1086
  await send(bot, chatId, t(languageFor(link), 'control.runStatus', { runId: run.id, status: run.status }), { editMessageId });
990
1087
  return;
991
1088
  }
1089
+ if (action === 'approval_decide') {
1090
+ const result = await localApi(link.user_id, `/api/orchestration/workflows/approvals/${encodeURIComponent(payload.approvalId)}`, {
1091
+ method: 'POST',
1092
+ body: {
1093
+ allow: payload.allow === true,
1094
+ source: 'telegram',
1095
+ },
1096
+ });
1097
+ const lang = languageFor(link);
1098
+ await send(bot, chatId, t(lang, 'control.approvalDecided', {
1099
+ approvalId: payload.approvalId,
1100
+ status: payload.allow === true ? 'approved' : 'denied',
1101
+ runId: result?.runId || '',
1102
+ }), { editMessageId });
1103
+ return showApprovalQueue({ bot, chatId, link });
1104
+ }
992
1105
  if (action === 'task_run') return runTaskMasterTask({ bot, chatId, link, taskId: payload.taskId });
993
1106
  if (action === 'install_provider') return startCliInstall({ bot, chatId, link, provider: payload.provider });
994
1107
  if (action === 'auth_provider') {