tide-commander 1.97.0 → 1.98.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 (69) hide show
  1. package/dist/assets/{BossLogsModal-CT25hD17.js → BossLogsModal-P6MiZVuY.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-9rS7AFkZ.js → BossSpawnModal-DZJ1Ngsz.js} +1 -1
  3. package/dist/assets/{ControlsModal-D-mymoM7.js → ControlsModal-BjV0a1kc.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-Ae-ZCeeP.js → DockerLogsModal-DtddubKa.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-DLOOpM0K.js → EmbeddedEditor-ByRepTwR.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-C9NLhWLo.js → GmailOAuthSetup-DS6NmxUi.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-1kzgrPV6.js → GoogleOAuthSetup-kQyKInRW.js} +1 -1
  8. package/dist/assets/{IframeModal-DKS0IFsr.js → IframeModal-BuTPDTx1.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CBvKOeud.js → IntegrationsPanel-Bm19ZAd6.js} +2 -2
  10. package/dist/assets/{LogViewerModal-Dlt8JfVg.js → LogViewerModal-DFlUtkDD.js} +1 -1
  11. package/dist/assets/{MonitoringModal-BM1IEZv6.js → MonitoringModal-BEEPwGu5.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-B1-HUHWZ.js → PM2LogsModal-Di5MjyxZ.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-DXmYo7fp.js → RestoreArchivedAreaModal-CNjmR7BX.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-CuUxSaPb.js → Scene2DCanvas-CA29sh9R.js} +1 -1
  15. package/dist/assets/{SceneManager-UD3IHY20.js → SceneManager-duyCA_TD.js} +1 -1
  16. package/dist/assets/{SkillsPanel-DjRBVrO2.js → SkillsPanel-CkV4ci4Q.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Csp81Dqn.js → SlackMultiInstanceSetup-iHZNQX3B.js} +1 -1
  18. package/dist/assets/{SpawnModal-dg0mH3d9.js → SpawnModal-bzQlnSQy.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CeBPRNNX.js → SubordinateAssignmentModal-D6Ak_XQ1.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-CT8aMQ5P.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-IIsptZgp.js → WorkflowEditorPanel-DcV9ErU4.js} +1 -1
  22. package/dist/assets/{index-BGh9tRSy.js → index-BQQV5Xax.js} +1 -1
  23. package/dist/assets/index-Ba8umpS_.js +1 -0
  24. package/dist/assets/{index-CNDUxsGy.js → index-C0fIrrTS.js} +1 -1
  25. package/dist/assets/index-CDHzSAgq.js +1 -0
  26. package/dist/assets/index-CK8NcQSU.css +1 -0
  27. package/dist/assets/{index-h-IcmGfB.js → index-Cirz97EK.js} +2 -2
  28. package/dist/assets/{index-sDgBtEgH.js → index-Cnj2pM08.js} +3 -3
  29. package/dist/assets/{index-DEI-vrXk.js → index-D2L5oc5D.js} +1 -1
  30. package/dist/assets/{index-CIqkVLo1.js → index-DR7uaDtK.js} +1 -1
  31. package/dist/assets/{index-CsyPNc8u.js → index-Dww2MWLN.js} +1 -1
  32. package/dist/assets/main-CD03IZnY.css +1 -0
  33. package/dist/assets/main-XbhAPjbi.js +214 -0
  34. package/dist/assets/{web-BgPjNMBK.js → web-C-JnApw7.js} +1 -1
  35. package/dist/assets/{web-BmPSJLwQ.js → web-C4LpSGoH.js} +1 -1
  36. package/dist/assets/{web-Dggt4D4N.js → web-CaPUSaID.js} +1 -1
  37. package/dist/index.html +2 -2
  38. package/dist/locales/en/config.json +44 -0
  39. package/dist/locales/en/terminal.json +10 -0
  40. package/dist/src/packages/server/claude/backend.js +42 -0
  41. package/dist/src/packages/server/claude/permission-prompt-server.mjs +188 -0
  42. package/dist/src/packages/server/claude/runner/process-lifecycle.js +3 -0
  43. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +8 -0
  44. package/dist/src/packages/server/claude/runner/tmux-helper.js +14 -0
  45. package/dist/src/packages/server/data/event-queries.js +143 -1
  46. package/dist/src/packages/server/data/migrations/007_whatsapp_messages.sql +48 -0
  47. package/dist/src/packages/server/index.js +1 -0
  48. package/dist/src/packages/server/integrations/gmail/gmail-client.js +139 -24
  49. package/dist/src/packages/server/integrations/gmail/gmail-routes.js +162 -0
  50. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +81 -0
  51. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +29 -0
  52. package/dist/src/packages/server/routes/agent-prompt.js +57 -0
  53. package/dist/src/packages/server/routes/index.js +6 -1
  54. package/dist/src/packages/server/routes/skills.js +193 -0
  55. package/dist/src/packages/server/routes/trigger-routes.js +74 -17
  56. package/dist/src/packages/server/routes/webhook-signatures.js +20 -7
  57. package/dist/src/packages/server/services/agent-prompt-service.js +100 -0
  58. package/dist/src/packages/server/services/index.js +1 -0
  59. package/dist/src/packages/server/websocket/handler.js +2 -1
  60. package/dist/src/packages/server/websocket/listeners/agent-prompt-listeners.js +13 -0
  61. package/dist/src/packages/server/websocket/listeners/index.js +2 -0
  62. package/dist/src/packages/shared/whatsapp-types.js +1 -0
  63. package/package.json +2 -2
  64. package/dist/assets/TriggerManagerPanel-D1QPpFhP.js +0 -9
  65. package/dist/assets/index-BdGz_GAe.css +0 -1
  66. package/dist/assets/index-CR9w26tq.js +0 -1
  67. package/dist/assets/index-vJkimYqD.js +0 -1
  68. package/dist/assets/main-BV_IuaBg.css +0 -1
  69. package/dist/assets/main-klWBzHh0.js +0 -214
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Skills Routes
3
+ * REST API endpoints for skill CRUD (custom and builtin-readable).
4
+ *
5
+ * Mirrors the WebSocket skill handlers in functionality but provides a
6
+ * stable HTTP surface for one-shot registration by other services or
7
+ * automation that doesn't want to maintain a WS subscription.
8
+ */
9
+ import { Router } from 'express';
10
+ import { skillService } from '../services/index.js';
11
+ import { createLogger, generateSlug } from '../utils/index.js';
12
+ const log = createLogger('SkillsRoute');
13
+ const router = Router();
14
+ let broadcastFn = null;
15
+ export function setBroadcast(fn) {
16
+ broadcastFn = fn;
17
+ }
18
+ function broadcast(type, payload) {
19
+ if (!broadcastFn)
20
+ return;
21
+ broadcastFn({ type, payload });
22
+ }
23
+ function validateUpsertBody(body) {
24
+ if (!body || typeof body !== 'object')
25
+ return { error: 'Request body must be a JSON object' };
26
+ const b = body;
27
+ if (typeof b.name !== 'string' || !b.name.trim())
28
+ return { error: 'name is required (string)' };
29
+ if (typeof b.description !== 'string')
30
+ return { error: 'description is required (string)' };
31
+ if (typeof b.content !== 'string')
32
+ return { error: 'content is required (string)' };
33
+ if (b.allowedTools !== undefined && (!Array.isArray(b.allowedTools) || b.allowedTools.some(t => typeof t !== 'string'))) {
34
+ return { error: 'allowedTools must be a string array' };
35
+ }
36
+ if (b.model !== undefined && typeof b.model !== 'string')
37
+ return { error: 'model must be a string' };
38
+ if (b.context !== undefined && b.context !== 'fork' && b.context !== 'inline')
39
+ return { error: "context must be 'fork' or 'inline'" };
40
+ if (b.enabled !== undefined && typeof b.enabled !== 'boolean')
41
+ return { error: 'enabled must be boolean' };
42
+ if (b.slug !== undefined && typeof b.slug !== 'string')
43
+ return { error: 'slug must be a string' };
44
+ return { body: b };
45
+ }
46
+ function normalizeSlug(slug) {
47
+ // Run through the canonical slug helper so callers can't sneak unsafe characters
48
+ // into the persisted file. generateSlug is idempotent on already-safe input.
49
+ return generateSlug(slug);
50
+ }
51
+ // ----------------------------------------------------------------------------
52
+ // GET /api/skills — list all
53
+ // ----------------------------------------------------------------------------
54
+ router.get('/', (_req, res) => {
55
+ try {
56
+ res.json({ skills: skillService.getAllSkills() });
57
+ }
58
+ catch (err) {
59
+ log.error(' Failed to list skills:', err);
60
+ res.status(500).json({ error: err.message });
61
+ }
62
+ });
63
+ // ----------------------------------------------------------------------------
64
+ // GET /api/skills/:slug — single
65
+ // ----------------------------------------------------------------------------
66
+ router.get('/:slug', (req, res) => {
67
+ try {
68
+ const slug = normalizeSlug(String(req.params.slug));
69
+ const skill = skillService.getSkillBySlug(slug);
70
+ if (!skill)
71
+ return res.status(404).json({ error: `Skill not found: ${slug}` });
72
+ res.json(skill);
73
+ }
74
+ catch (err) {
75
+ log.error(' Failed to fetch skill:', err);
76
+ res.status(500).json({ error: err.message });
77
+ }
78
+ });
79
+ // ----------------------------------------------------------------------------
80
+ // Upsert helper shared by PUT /:slug and POST /
81
+ // ----------------------------------------------------------------------------
82
+ function upsertSkill(slug, body, res) {
83
+ const existing = skillService.getSkillBySlug(slug);
84
+ if (!existing) {
85
+ const created = skillService.createSkill({
86
+ slug,
87
+ name: body.name,
88
+ description: body.description,
89
+ content: body.content,
90
+ allowedTools: body.allowedTools,
91
+ model: body.model,
92
+ context: body.context,
93
+ assignedAgentIds: body.assignedAgentIds,
94
+ assignedAgentClasses: body.assignedAgentClasses,
95
+ enabled: body.enabled,
96
+ });
97
+ broadcast('skill_created', created);
98
+ log.log(` Created skill via HTTP: ${created.slug} (${created.id})`);
99
+ res.status(201).json(created);
100
+ return;
101
+ }
102
+ if (existing.builtin) {
103
+ res.status(409).json({ error: `slug '${slug}' is owned by a builtin skill` });
104
+ return;
105
+ }
106
+ const updates = {
107
+ name: body.name,
108
+ description: body.description,
109
+ content: body.content,
110
+ slug,
111
+ };
112
+ if (body.allowedTools !== undefined)
113
+ updates.allowedTools = body.allowedTools;
114
+ if (body.model !== undefined)
115
+ updates.model = body.model;
116
+ if (body.context !== undefined)
117
+ updates.context = body.context;
118
+ if (body.assignedAgentIds !== undefined)
119
+ updates.assignedAgentIds = body.assignedAgentIds;
120
+ if (body.assignedAgentClasses !== undefined)
121
+ updates.assignedAgentClasses = body.assignedAgentClasses;
122
+ if (body.enabled !== undefined)
123
+ updates.enabled = body.enabled;
124
+ const updated = skillService.updateSkill(existing.id, updates);
125
+ if (!updated) {
126
+ res.status(500).json({ error: `Failed to update skill ${slug}` });
127
+ return;
128
+ }
129
+ broadcast('skill_updated', updated);
130
+ log.log(` Updated skill via HTTP: ${updated.slug} (${updated.id})`);
131
+ res.status(200).json(updated);
132
+ }
133
+ // ----------------------------------------------------------------------------
134
+ // PUT /api/skills/:slug — upsert by slug in URL
135
+ // ----------------------------------------------------------------------------
136
+ router.put('/:slug', (req, res) => {
137
+ try {
138
+ const urlSlug = normalizeSlug(String(req.params.slug));
139
+ const v = validateUpsertBody(req.body);
140
+ if ('error' in v)
141
+ return res.status(400).json({ error: v.error });
142
+ if (v.body.slug !== undefined && normalizeSlug(v.body.slug) !== urlSlug) {
143
+ return res.status(400).json({ error: `URL slug '${urlSlug}' does not match body slug '${v.body.slug}'` });
144
+ }
145
+ upsertSkill(urlSlug, v.body, res);
146
+ }
147
+ catch (err) {
148
+ log.error(' Failed to upsert skill:', err);
149
+ res.status(500).json({ error: err.message });
150
+ }
151
+ });
152
+ // ----------------------------------------------------------------------------
153
+ // POST /api/skills — upsert with slug from body (or derived from name)
154
+ // ----------------------------------------------------------------------------
155
+ router.post('/', (req, res) => {
156
+ try {
157
+ const v = validateUpsertBody(req.body);
158
+ if ('error' in v)
159
+ return res.status(400).json({ error: v.error });
160
+ const slug = v.body.slug ? normalizeSlug(v.body.slug) : generateSlug(v.body.name);
161
+ if (!slug)
162
+ return res.status(400).json({ error: 'slug could not be derived from name' });
163
+ upsertSkill(slug, v.body, res);
164
+ }
165
+ catch (err) {
166
+ log.error(' Failed to upsert skill:', err);
167
+ res.status(500).json({ error: err.message });
168
+ }
169
+ });
170
+ // ----------------------------------------------------------------------------
171
+ // DELETE /api/skills/:slug — delete custom skill (409 if builtin)
172
+ // ----------------------------------------------------------------------------
173
+ router.delete('/:slug', (req, res) => {
174
+ try {
175
+ const slug = normalizeSlug(String(req.params.slug));
176
+ const skill = skillService.getSkillBySlug(slug);
177
+ if (!skill)
178
+ return res.status(404).json({ error: `Skill not found: ${slug}` });
179
+ if (skill.builtin)
180
+ return res.status(409).json({ error: `Cannot delete builtin skill '${slug}'` });
181
+ const ok = skillService.deleteSkill(skill.id);
182
+ if (!ok)
183
+ return res.status(500).json({ error: `Failed to delete skill ${slug}` });
184
+ broadcast('skill_deleted', { id: skill.id });
185
+ log.log(` Deleted skill via HTTP: ${slug} (${skill.id})`);
186
+ res.status(204).end();
187
+ }
188
+ catch (err) {
189
+ log.error(' Failed to delete skill:', err);
190
+ res.status(500).json({ error: err.message });
191
+ }
192
+ });
193
+ export default router;
@@ -15,12 +15,14 @@
15
15
  * GET /api/triggers/:id/events - Get trigger fire history
16
16
  */
17
17
  import { Router } from 'express';
18
+ import * as crypto from 'crypto';
18
19
  import * as triggerService from '../services/trigger-service.js';
19
20
  import * as cronService from '../services/cron-service.js';
20
21
  import { createLogger } from '../utils/logger.js';
21
- import { detectWebhookProvider, verifyHmacSignature, getWebhookHmacPayload, GITHUB_SIGNATURE_HEADER, BITBUCKET_SIGNATURE_HEADER, BITBUCKET_EVENT_HEADER, } from './webhook-signatures.js';
22
+ import { detectWebhookProvider, verifyHmacSignature, getWebhookHmacPayload, GITHUB_SIGNATURE_HEADER, BITBUCKET_SIGNATURE_HEADER, BITBUCKET_EVENT_HEADER, JIRA_SIGNATURE_HEADER, } from './webhook-signatures.js';
22
23
  import { WebhookDedupeCache } from './webhook-dedupe.js';
23
24
  import { isBitbucketAuthorLoop } from './bitbucket-author-loop.js';
25
+ import { jiraTriggerHandler } from '../integrations/jira/jira-trigger-handler.js';
24
26
  const log = createLogger('TriggerRoutes');
25
27
  const router = Router();
26
28
  // Process-wide dedupe cache for webhook deliveries. Bitbucket retries reuse
@@ -105,33 +107,34 @@ router.post('/webhook/:triggerId', async (req, res) => {
105
107
  res.status(400).json({ error: 'Trigger is disabled' });
106
108
  return;
107
109
  }
108
- // Both `webhook` and `bitbucket` route through this handler the signature,
109
- // dedupe, and author-loop helpers are header-driven (auto-detect Bitbucket
110
- // vs GitHub via X-Event-Key vs X-GitHub-Event), so they work for either type.
111
- if (trigger.type !== 'webhook' && trigger.type !== 'bitbucket') {
110
+ if (trigger.type !== 'webhook' && trigger.type !== 'bitbucket' && trigger.type !== 'jira') {
112
111
  res.status(400).json({ error: 'Not a webhook-receivable trigger', triggerType: trigger.type });
113
112
  return;
114
113
  }
115
- // Validate HMAC secret if configured
114
+ // Jira fails closed when no secret is configured: classic Cloud webhooks are
115
+ // unsigned, so the `?secret=<shared>` query-string is the only auth path —
116
+ // leaving it empty would let anyone fire the trigger by URL guess.
117
+ if (trigger.type === 'jira' && !trigger.config.secret) {
118
+ log.warn(`Webhook trigger ${triggerId} rejected: Jira trigger has no secret configured`);
119
+ res.status(401).json({ error: 'Jira trigger requires a configured secret' });
120
+ return;
121
+ }
116
122
  if (trigger.config.secret) {
117
123
  const provider = detectWebhookProvider(req.headers);
118
124
  const githubSig = req.headers[GITHUB_SIGNATURE_HEADER];
119
125
  const bitbucketSig = req.headers[BITBUCKET_SIGNATURE_HEADER];
126
+ const jiraSig = req.headers[JIRA_SIGNATURE_HEADER];
120
127
  const plainSecret = req.headers['x-webhook-secret'];
121
- // Pick the signature whose header matches the detected provider — falls back
122
- // to whichever HMAC header is present, then to the plain X-Webhook-Secret.
128
+ const querySecret = typeof req.query.secret === 'string' ? req.query.secret : undefined;
123
129
  const hmacSig = provider === 'bitbucket' ? bitbucketSig
124
130
  : provider === 'github' ? githubSig
125
- : (githubSig || bitbucketSig);
126
- if (!hmacSig && !plainSecret) {
131
+ : provider === 'jira' ? jiraSig
132
+ : (githubSig || bitbucketSig || jiraSig);
133
+ if (!hmacSig && !plainSecret && !querySecret) {
127
134
  res.status(401).json({ error: 'Missing signature' });
128
135
  return;
129
136
  }
130
137
  if (hmacSig) {
131
- // GitHub and Bitbucket Cloud both use HMAC-SHA256 with the `sha256=` prefix.
132
- // Hash the raw request bytes captured by the webhook-scoped JSON parser
133
- // (see app.ts) — re-serializing req.body would risk a false reject on
134
- // key reordering / whitespace differences.
135
138
  const { payload, usedFallback } = getWebhookHmacPayload(req);
136
139
  if (usedFallback) {
137
140
  log.warn(`Webhook trigger ${triggerId}: rawBody unavailable, falling back to JSON.stringify — signature may not match`);
@@ -144,11 +147,14 @@ router.post('/webhook/:triggerId', async (req, res) => {
144
147
  }
145
148
  }
146
149
  else {
147
- // Direct comparison for X-Webhook-Secret
148
- if (plainSecret !== trigger.config.secret) {
150
+ const candidate = plainSecret ?? querySecret ?? '';
151
+ if (!constantTimeEqual(candidate, trigger.config.secret)) {
149
152
  res.status(401).json({ error: 'Invalid secret' });
150
153
  return;
151
154
  }
155
+ if (querySecret && trigger.type === 'jira') {
156
+ log.warn(`Webhook trigger ${triggerId}: Jira ?secret= fallback used (no HMAC) — Cloud classic webhooks are unsigned, prefer Cloud Signed Webhooks when possible`);
157
+ }
152
158
  }
153
159
  }
154
160
  // Idempotency: short-circuit Bitbucket retry deliveries (same X-Request-UUID
@@ -173,12 +179,36 @@ router.post('/webhook/:triggerId', async (req, res) => {
173
179
  res.json({ skipped: 'author-loop-guard' });
174
180
  return;
175
181
  }
176
- // Extract fields from payload
177
182
  const variables = {
178
183
  'trigger.name': trigger.name,
179
184
  timestamp: new Date().toISOString(),
180
185
  payload: JSON.stringify(req.body),
181
186
  };
187
+ if (trigger.type === 'jira') {
188
+ const event = {
189
+ source: 'jira',
190
+ type: (req.body && typeof req.body === 'object' && 'webhookEvent' in req.body
191
+ ? String(req.body.webhookEvent ?? 'jira')
192
+ : 'jira'),
193
+ data: normalizeJiraPayload(req.body),
194
+ timestamp: Date.now(),
195
+ };
196
+ if (!jiraTriggerHandler.structuralMatch(trigger, event)) {
197
+ res.json({ fired: false, reason: 'structural-mismatch' });
198
+ return;
199
+ }
200
+ Object.assign(variables, jiraTriggerHandler.extractVariables(trigger, event));
201
+ try {
202
+ await triggerService.fireTrigger(triggerId, variables, { rawPayload: event.data });
203
+ res.json({ fired: true });
204
+ }
205
+ catch (err) {
206
+ const message = err instanceof Error ? err.message : 'Failed to fire trigger';
207
+ log.error(`Webhook trigger ${triggerId} failed:`, err);
208
+ res.status(500).json({ error: message });
209
+ }
210
+ return;
211
+ }
182
212
  // `extractFields` is declared on WebhookTrigger.config but is conceptually
183
213
  // generic: same-shape JSON-path extraction works for any header-receivable
184
214
  // type. Read through the union via a structural cast (mirrors the fallback
@@ -301,4 +331,31 @@ function getNestedValue(obj, path) {
301
331
  }
302
332
  return current;
303
333
  }
334
+ function constantTimeEqual(a, b) {
335
+ const ab = Buffer.from(a);
336
+ const bb = Buffer.from(b);
337
+ if (ab.length !== bb.length)
338
+ return false;
339
+ return crypto.timingSafeEqual(ab, bb);
340
+ }
341
+ /**
342
+ * Reshape Jira Server / Data Center payloads into the Cloud shape the trigger
343
+ * handler reads. Server fires events like `issue_updated` via
344
+ * `issue_event_type_name`; Cloud uses `webhookEvent: "jira:issue_updated"`.
345
+ * Cloud nests the issue under `issue`; Server does too, so no key rewrite —
346
+ * we just synthesize a `webhookEvent` when missing so the handler's filter
347
+ * by event type works against both variants without per-shape branching.
348
+ */
349
+ function normalizeJiraPayload(body) {
350
+ if (!body || typeof body !== 'object')
351
+ return {};
352
+ const src = body;
353
+ if (src.webhookEvent || !src.issue_event_type_name)
354
+ return src;
355
+ const eventName = String(src.issue_event_type_name);
356
+ const synthesized = eventName.startsWith('comment_')
357
+ ? eventName
358
+ : `jira:${eventName.startsWith('issue_') ? eventName : `issue_${eventName}`}`;
359
+ return { ...src, webhookEvent: synthesized };
360
+ }
304
361
  export default router;
@@ -2,12 +2,18 @@
2
2
  * Webhook signature verification for incoming HMAC-SHA256 webhooks.
3
3
  *
4
4
  * Detects provider by header presence:
5
- * - GitHub-style: `X-GitHub-Event` + `X-Hub-Signature-256`
6
- * - Bitbucket Cloud: `X-Event-Key` + `X-Hub-Signature` (note: different header)
5
+ * - GitHub-style: `X-GitHub-Event` + `X-Hub-Signature-256`
6
+ * - Bitbucket Cloud: `X-Event-Key` + `X-Hub-Signature`
7
+ * - Jira (Cloud signed / Server signed plugins):
8
+ * `X-Atlassian-Webhook-Identifier` + `X-Hub-Signature`
7
9
  *
8
- * Both providers use the same algorithm (HMAC-SHA256, hex-encoded, with the
10
+ * All providers use the same algorithm (HMAC-SHA256, hex-encoded, with the
9
11
  * `sha256=` prefix); only the header name differs. The trigger's per-instance
10
- * secret is reused across both — the trigger config doesn't distinguish.
12
+ * secret is reused across all — the trigger config doesn't distinguish.
13
+ *
14
+ * Jira Cloud's classic admin webhooks don't sign payloads. For that case the
15
+ * caller falls back to a `?secret=<shared>` query-string check rather than
16
+ * HMAC; the helpers here cover the signed paths only.
11
17
  *
12
18
  * Body source: HMAC is computed over the *raw* request bytes captured by the
13
19
  * scoped `express.json({ verify })` middleware in app.ts. Re-serializing via
@@ -20,18 +26,25 @@
20
26
  import * as crypto from 'crypto';
21
27
  export const GITHUB_SIGNATURE_HEADER = 'x-hub-signature-256';
22
28
  export const BITBUCKET_SIGNATURE_HEADER = 'x-hub-signature';
29
+ export const JIRA_SIGNATURE_HEADER = 'x-hub-signature';
23
30
  export const GITHUB_EVENT_HEADER = 'x-github-event';
24
31
  export const BITBUCKET_EVENT_HEADER = 'x-event-key';
32
+ export const JIRA_WEBHOOK_ID_HEADER = 'x-atlassian-webhook-identifier';
25
33
  /**
26
- * Detect provider by event-key header. Bitbucket sends `X-Event-Key`,
27
- * GitHub sends `X-GitHub-Event`. Either header can appear in lowercase
28
- * via Node's normalization. Returns null when neither is present.
34
+ * Detect provider by identifying header. Bitbucket sends `X-Event-Key`,
35
+ * GitHub sends `X-GitHub-Event`, Jira sends `X-Atlassian-Webhook-Identifier`.
36
+ * Headers arrive lowercased via Node's normalization. Bitbucket wins over
37
+ * Jira when both are somehow present (Jira-from-Bitbucket is implausible;
38
+ * either way the HMAC step still gates dispatch). Returns null when no
39
+ * identifying header is present.
29
40
  */
30
41
  export function detectWebhookProvider(headers) {
31
42
  if (headers[BITBUCKET_EVENT_HEADER])
32
43
  return 'bitbucket';
33
44
  if (headers[GITHUB_EVENT_HEADER])
34
45
  return 'github';
46
+ if (headers[JIRA_WEBHOOK_ID_HEADER])
47
+ return 'jira';
35
48
  return null;
36
49
  }
37
50
  /**
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Agent Prompt Service
3
+ *
4
+ * Bridges the MCP permission-prompt-tool flow to the Tide Commander UI.
5
+ * When a bypass-mode Claude agent invokes AskUserQuestion or ExitPlanMode the
6
+ * MCP server (src/packages/server/claude/permission-prompt-server.mjs) POSTs
7
+ * here; we hold the request, broadcast it to the UI, and resolve the waiting
8
+ * promise once the user clicks Approve / picks an answer (or on timeout).
9
+ *
10
+ * Mirrors permission-service.ts intentionally — the data flow is the same.
11
+ */
12
+ import { createLogger } from '../utils/logger.js';
13
+ const log = createLogger('AgentPrompt');
14
+ const pending = new Map();
15
+ const listeners = new Set();
16
+ const resolvedListeners = new Set();
17
+ // Plan/clarification prompts can sit for a while in interactive review — give
18
+ // the user 10 minutes before falling back to a deny.
19
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
20
+ export function subscribe(listener) {
21
+ listeners.add(listener);
22
+ return () => listeners.delete(listener);
23
+ }
24
+ export function subscribeResolved(listener) {
25
+ resolvedListeners.add(listener);
26
+ return () => resolvedListeners.delete(listener);
27
+ }
28
+ function broadcast(prompt) {
29
+ listeners.forEach((l) => l(prompt));
30
+ }
31
+ function broadcastResolved(requestId, approved) {
32
+ resolvedListeners.forEach((l) => l(requestId, approved));
33
+ }
34
+ /**
35
+ * Create a prompt and wait for the user's response (or timeout).
36
+ * Called by the agent-prompt HTTP route on behalf of the MCP server.
37
+ */
38
+ export async function createPrompt(args) {
39
+ const prompt = {
40
+ id: args.id,
41
+ agentId: args.agentId,
42
+ tool: args.tool,
43
+ input: args.input,
44
+ status: 'pending',
45
+ timestamp: Date.now(),
46
+ };
47
+ log.log(`Prompt created: ${args.id} tool=${args.tool} agent=${args.agentId}`);
48
+ return new Promise((resolve) => {
49
+ const timeout = setTimeout(() => {
50
+ log.log(`Prompt ${args.id} timed out — denying`);
51
+ pending.delete(args.id);
52
+ broadcastResolved(args.id, false);
53
+ resolve({ requestId: args.id, approved: false, reason: 'Prompt timed out' });
54
+ }, DEFAULT_TIMEOUT_MS);
55
+ pending.set(args.id, { prompt, resolve, timeout });
56
+ broadcast(prompt);
57
+ });
58
+ }
59
+ /**
60
+ * Deliver a user response. Called by the UI via HTTP POST or WS message.
61
+ */
62
+ export function respondToPrompt(response) {
63
+ const entry = pending.get(response.requestId);
64
+ if (!entry) {
65
+ log.log(`No pending prompt for ${response.requestId}`);
66
+ return false;
67
+ }
68
+ clearTimeout(entry.timeout);
69
+ pending.delete(response.requestId);
70
+ entry.prompt.status = 'answered';
71
+ log.log(`Prompt ${response.requestId} answered approved=${response.approved}`);
72
+ broadcastResolved(response.requestId, response.approved);
73
+ entry.resolve(response);
74
+ return true;
75
+ }
76
+ export function getPendingPrompts() {
77
+ return Array.from(pending.values()).map((p) => p.prompt);
78
+ }
79
+ export function getPendingPromptsForAgent(agentId) {
80
+ return Array.from(pending.values())
81
+ .filter((p) => p.prompt.agentId === agentId)
82
+ .map((p) => p.prompt);
83
+ }
84
+ /**
85
+ * Cancel all pending prompts for an agent (used when an agent is stopped).
86
+ */
87
+ export function cancelPromptsForAgent(agentId) {
88
+ const cancelled = [];
89
+ for (const [id, entry] of pending) {
90
+ if (entry.prompt.agentId === agentId) {
91
+ log.log(`Cancelling prompt ${id} for stopped agent ${agentId}`);
92
+ clearTimeout(entry.timeout);
93
+ pending.delete(id);
94
+ broadcastResolved(id, false);
95
+ entry.resolve({ requestId: id, approved: false, reason: 'Agent was stopped' });
96
+ cancelled.push(id);
97
+ }
98
+ }
99
+ return cancelled;
100
+ }
@@ -6,6 +6,7 @@ export * as agentService from './agent-service.js';
6
6
  export * as claudeService from './claude-service.js';
7
7
  export * as runtimeService from './runtime-service.js';
8
8
  export * as permissionService from './permission-service.js';
9
+ export * as agentPromptService from './agent-prompt-service.js';
9
10
  export * as bossService from './boss-service.js';
10
11
  export * as skillService from './skill-service.js';
11
12
  export * as customClassService from './custom-class-service.js';
@@ -6,7 +6,7 @@ import { WebSocketServer, WebSocket } from 'ws';
6
6
  import { agentService, bossMessageService, customClassService, permissionService, runtimeService, skillService, triggerService, workflowService, } from '../services/index.js';
7
7
  import { loadAreas, loadBuildings } from '../data/index.js';
8
8
  import { logger } from '../utils/index.js';
9
- import { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast } from '../routes/index.js';
9
+ import { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast } from '../routes/index.js';
10
10
  import { validateWebSocketAuth, isAuthEnabled } from '../auth/index.js';
11
11
  import { incrementWsSent, incrementWsReceived, setWsClientsCount } from '../routes/perf.js';
12
12
  import { handleSpawnAgent, handleKillAgent, handleStopAgent, handleClearContext, handleRestoreSession, handleRequestSessionHistory, handleCollapseContext, handleRequestContextStats, handleMoveAgent, handleRemoveAgent, handleRenameAgent, handleUpdateAgentProperties, handleCreateDirectory, handleReattachAgent, } from './handlers/agent-handler.js';
@@ -302,6 +302,7 @@ export function init(server) {
302
302
  setAgentsBroadcast(broadcast);
303
303
  setTriggerBroadcast(broadcast);
304
304
  setBuildingsBroadcast(broadcast);
305
+ setSkillsBroadcast(broadcast);
305
306
  log.log('Handler initialized');
306
307
  return wss;
307
308
  }
@@ -0,0 +1,13 @@
1
+ import { agentPromptService } from '../../services/index.js';
2
+ import { logger } from '../../utils/index.js';
3
+ const log = logger.ws;
4
+ export function setupAgentPromptListeners(ctx) {
5
+ agentPromptService.subscribe((prompt) => {
6
+ log.log(` Broadcasting agent_prompt_request: ${prompt.id} tool=${prompt.tool} agent=${prompt.agentId}`);
7
+ ctx.broadcast({ type: 'agent_prompt_request', payload: prompt });
8
+ });
9
+ agentPromptService.subscribeResolved((requestId, approved) => {
10
+ log.log(` Broadcasting agent_prompt_resolved: ${requestId} approved=${approved}`);
11
+ ctx.broadcast({ type: 'agent_prompt_resolved', payload: { requestId, approved } });
12
+ });
13
+ }
@@ -1,6 +1,7 @@
1
1
  import { agentService } from '../../services/index.js';
2
2
  import { setupBossListeners } from './boss-listeners.js';
3
3
  import { setupPermissionListeners } from './permission-listeners.js';
4
+ import { setupAgentPromptListeners } from './agent-prompt-listeners.js';
4
5
  import { setupRuntimeListeners } from './runtime-listeners.js';
5
6
  import { setupSkillListeners } from './skill-listeners.js';
6
7
  export function setupServiceListeners(ctx) {
@@ -25,6 +26,7 @@ export function setupServiceListeners(ctx) {
25
26
  });
26
27
  setupRuntimeListeners(ctx);
27
28
  setupPermissionListeners(ctx);
29
+ setupAgentPromptListeners(ctx);
28
30
  setupBossListeners(ctx);
29
31
  setupSkillListeners(ctx);
30
32
  }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.97.0",
3
+ "version": "1.98.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "docs:preview": "npm --prefix src/packages/landing run preview",
26
26
  "docs:install": "npm --prefix src/packages/landing install",
27
27
  "build": "npm run build:types && vite build && npm run build:server",
28
- "build:server": "tsc -p tsconfig.server.json && cp -r src/packages/server/data/migrations dist/src/packages/server/data/",
28
+ "build:server": "tsc -p tsconfig.server.json && cp -r src/packages/server/data/migrations dist/src/packages/server/data/ && cp src/packages/server/claude/permission-prompt-server.mjs dist/src/packages/server/claude/",
29
29
  "preview": "vite preview",
30
30
  "setup": "tsx src/packages/server/setup.ts",
31
31
  "lint": "eslint src/",