tide-commander 1.97.0 → 1.99.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.
- package/dist/assets/{BossLogsModal-CT25hD17.js → BossLogsModal-CDel834o.js} +1 -1
- package/dist/assets/{BossSpawnModal-9rS7AFkZ.js → BossSpawnModal-BB9wL5VV.js} +1 -1
- package/dist/assets/{ControlsModal-D-mymoM7.js → ControlsModal-D5RE5MvT.js} +1 -1
- package/dist/assets/{DockerLogsModal-Ae-ZCeeP.js → DockerLogsModal-B27P1JpZ.js} +1 -1
- package/dist/assets/{EmbeddedEditor-DLOOpM0K.js → EmbeddedEditor-DP1jqsT_.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-C9NLhWLo.js → GmailOAuthSetup-DvuL5G8Q.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-1kzgrPV6.js → GoogleOAuthSetup-CG6bSCjv.js} +1 -1
- package/dist/assets/{IframeModal-DKS0IFsr.js → IframeModal-ClnUGmJV.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CBvKOeud.js → IntegrationsPanel-0vdfxZRq.js} +2 -2
- package/dist/assets/{LogViewerModal-Dlt8JfVg.js → LogViewerModal-DLQlrZ4O.js} +1 -1
- package/dist/assets/{MonitoringModal-BM1IEZv6.js → MonitoringModal-DiC9TNCy.js} +1 -1
- package/dist/assets/{PM2LogsModal-B1-HUHWZ.js → PM2LogsModal-BgPrnaP5.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-DXmYo7fp.js → RestoreArchivedAreaModal-CIN1OrOW.js} +1 -1
- package/dist/assets/{Scene2DCanvas-CuUxSaPb.js → Scene2DCanvas-Bap6brvv.js} +1 -1
- package/dist/assets/{SceneManager-UD3IHY20.js → SceneManager-CidCW0PR.js} +1 -1
- package/dist/assets/{SkillsPanel-DjRBVrO2.js → SkillsPanel-DHQTPaP2.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Csp81Dqn.js → SlackMultiInstanceSetup-CQK4D89W.js} +1 -1
- package/dist/assets/{SpawnModal-dg0mH3d9.js → SpawnModal-Cx_k3HHC.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CeBPRNNX.js → SubordinateAssignmentModal-C1DOv51H.js} +1 -1
- package/dist/assets/TriggerManagerPanel-jP5RBK2L.js +9 -0
- package/dist/assets/{WorkflowEditorPanel-IIsptZgp.js → WorkflowEditorPanel-Dh6mZ8M4.js} +1 -1
- package/dist/assets/{index-h-IcmGfB.js → index-B-ttQFx4.js} +2 -2
- package/dist/assets/index-BXnThzaG.js +11 -0
- package/dist/assets/index-CK8NcQSU.css +1 -0
- package/dist/assets/{index-CNDUxsGy.js → index-CYwFXTQZ.js} +1 -1
- package/dist/assets/{index-sDgBtEgH.js → index-Cwlm-Pqi.js} +3 -3
- package/dist/assets/index-D96LXKm4.js +1 -0
- package/dist/assets/{index-BGh9tRSy.js → index-DP5sMNS9.js} +1 -1
- package/dist/assets/{index-CsyPNc8u.js → index-DfEbuBH8.js} +1 -1
- package/dist/assets/{index-DEI-vrXk.js → index-DsKaX6TJ.js} +1 -1
- package/dist/assets/{index-CIqkVLo1.js → index-enJvXAbe.js} +1 -1
- package/dist/assets/main-B7wf_xU_.js +214 -0
- package/dist/assets/main-DLzFxLC1.css +1 -0
- package/dist/assets/{web-BmPSJLwQ.js → web-BHmmnvF7.js} +1 -1
- package/dist/assets/{web-Dggt4D4N.js → web-IGuhG0xr.js} +1 -1
- package/dist/assets/{web-BgPjNMBK.js → web-SOehUGgT.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/config.json +60 -1
- package/dist/locales/en/terminal.json +10 -0
- package/dist/src/packages/server/claude/backend.js +42 -0
- package/dist/src/packages/server/claude/permission-prompt-server.mjs +188 -0
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +3 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +8 -0
- package/dist/src/packages/server/claude/runner/tmux-helper.js +14 -0
- package/dist/src/packages/server/data/event-queries.js +143 -1
- package/dist/src/packages/server/data/migrations/007_whatsapp_messages.sql +48 -0
- package/dist/src/packages/server/index.js +1 -0
- package/dist/src/packages/server/integrations/gmail/gmail-client.js +139 -24
- package/dist/src/packages/server/integrations/gmail/gmail-routes.js +162 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +81 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +29 -0
- package/dist/src/packages/server/routes/agent-prompt.js +57 -0
- package/dist/src/packages/server/routes/index.js +8 -1
- package/dist/src/packages/server/routes/skills.js +193 -0
- package/dist/src/packages/server/routes/system.js +156 -0
- package/dist/src/packages/server/routes/trigger-routes.js +74 -17
- package/dist/src/packages/server/routes/webhook-signatures.js +20 -7
- package/dist/src/packages/server/services/agent-prompt-service.js +100 -0
- package/dist/src/packages/server/services/index.js +1 -0
- package/dist/src/packages/server/services/self-update-service.js +191 -0
- package/dist/src/packages/server/websocket/handler.js +2 -1
- package/dist/src/packages/server/websocket/listeners/agent-prompt-listeners.js +13 -0
- package/dist/src/packages/server/websocket/listeners/index.js +2 -0
- package/dist/src/packages/shared/whatsapp-types.js +1 -0
- package/package.json +2 -2
- package/dist/assets/TriggerManagerPanel-D1QPpFhP.js +0 -9
- package/dist/assets/index-BdGz_GAe.css +0 -1
- package/dist/assets/index-CR9w26tq.js +0 -1
- package/dist/assets/index-vJkimYqD.js +0 -1
- package/dist/assets/main-BV_IuaBg.css +0 -1
- 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;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System Routes
|
|
3
|
+
* Endpoints for inspecting / updating the running Tide Commander install.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { createLogger } from '../utils/logger.js';
|
|
7
|
+
import { fetchLatestNpmVersion, getVersionRelation } from '../../shared/version.js';
|
|
8
|
+
import { getInstallInfo, isAutoUpdateSupported, runNpmGlobalUpdate, } from '../services/self-update-service.js';
|
|
9
|
+
const log = createLogger('SystemRoutes');
|
|
10
|
+
const router = Router();
|
|
11
|
+
const PACKAGE_NAME = 'tide-commander';
|
|
12
|
+
// Guard: only one self-update may run at a time
|
|
13
|
+
let updateInProgress = false;
|
|
14
|
+
/**
|
|
15
|
+
* GET /api/system/install-info
|
|
16
|
+
*
|
|
17
|
+
* Returns enough info for the UI to decide whether to render the "Update now"
|
|
18
|
+
* button and which command to suggest if auto-update isn't supported.
|
|
19
|
+
*/
|
|
20
|
+
router.get('/install-info', async (_req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const info = getInstallInfo();
|
|
23
|
+
const latestVersion = await fetchLatestNpmVersion(PACKAGE_NAME);
|
|
24
|
+
const relation = latestVersion
|
|
25
|
+
? getVersionRelation(info.currentVersion, latestVersion)
|
|
26
|
+
: 'unknown';
|
|
27
|
+
const updateAvailable = relation === 'behind';
|
|
28
|
+
res.json({
|
|
29
|
+
isGlobalInstall: info.isGlobalInstall,
|
|
30
|
+
packageManager: info.packageManager,
|
|
31
|
+
installRoot: info.installRoot,
|
|
32
|
+
currentVersion: info.currentVersion,
|
|
33
|
+
latestVersion,
|
|
34
|
+
updateAvailable,
|
|
35
|
+
autoUpdateSupported: isAutoUpdateSupported(info),
|
|
36
|
+
suggestedManualCommand: info.suggestedManualCommand,
|
|
37
|
+
reason: info.reason,
|
|
38
|
+
updateInProgress,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const message = err.message;
|
|
43
|
+
log.error(`Failed to get install info: ${message}`);
|
|
44
|
+
res.status(500).json({ error: message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* POST /api/system/self-update
|
|
49
|
+
*
|
|
50
|
+
* Streams the output of `npm install -g tide-commander@latest` via SSE.
|
|
51
|
+
*
|
|
52
|
+
* SSE events:
|
|
53
|
+
* - start { message }
|
|
54
|
+
* - stdout { chunk }
|
|
55
|
+
* - stderr { chunk }
|
|
56
|
+
* - done { success, exitCode, newVersion, requiresRestart }
|
|
57
|
+
* - error { message, permissionDenied, suggestedManualCommand }
|
|
58
|
+
*
|
|
59
|
+
* On success the server schedules its own exit so the next launch picks up
|
|
60
|
+
* the new binary. The user must relaunch tide-commander manually.
|
|
61
|
+
*/
|
|
62
|
+
router.post('/self-update', async (_req, res) => {
|
|
63
|
+
const info = getInstallInfo();
|
|
64
|
+
if (!info.isGlobalInstall) {
|
|
65
|
+
res.status(400).json({
|
|
66
|
+
error: 'Auto-update is only available when running from a global install.',
|
|
67
|
+
reason: info.reason,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!isAutoUpdateSupported(info)) {
|
|
72
|
+
res.status(400).json({
|
|
73
|
+
error: `Auto-update is not supported for package manager: ${info.packageManager}`,
|
|
74
|
+
suggestedManualCommand: info.suggestedManualCommand,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (updateInProgress) {
|
|
79
|
+
res.status(409).json({ error: 'An update is already in progress.' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
updateInProgress = true;
|
|
83
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
84
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
85
|
+
res.setHeader('Connection', 'keep-alive');
|
|
86
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
87
|
+
res.flushHeaders?.();
|
|
88
|
+
const send = (event, data) => {
|
|
89
|
+
res.write(`event: ${event}\n`);
|
|
90
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
91
|
+
};
|
|
92
|
+
// Keepalive comment every 15s in case the install takes a while
|
|
93
|
+
const keepalive = setInterval(() => {
|
|
94
|
+
try {
|
|
95
|
+
res.write(`: keepalive ${Date.now()}\n\n`);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// socket closed
|
|
99
|
+
}
|
|
100
|
+
}, 15000);
|
|
101
|
+
send('start', { message: `Running npm install -g ${PACKAGE_NAME}@latest...` });
|
|
102
|
+
try {
|
|
103
|
+
const result = await runNpmGlobalUpdate({
|
|
104
|
+
onStdout: (chunk) => send('stdout', { chunk }),
|
|
105
|
+
onStderr: (chunk) => send('stderr', { chunk }),
|
|
106
|
+
});
|
|
107
|
+
clearInterval(keepalive);
|
|
108
|
+
if (result.exitCode === 0) {
|
|
109
|
+
const newVersion = (await fetchLatestNpmVersion(PACKAGE_NAME)) ?? null;
|
|
110
|
+
send('done', {
|
|
111
|
+
success: true,
|
|
112
|
+
exitCode: 0,
|
|
113
|
+
newVersion,
|
|
114
|
+
requiresRestart: true,
|
|
115
|
+
message: 'Update installed. Please restart Tide Commander from your terminal.',
|
|
116
|
+
});
|
|
117
|
+
res.end();
|
|
118
|
+
// Give the client ~1.5s to render the success message before we exit.
|
|
119
|
+
// The user must manually restart with `tide-commander` from the terminal.
|
|
120
|
+
log.log('Update succeeded — scheduling server exit in 1500ms to release the old binary.');
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
log.log('Exiting after successful update. Restart manually with: tide-commander');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}, 1500);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const errMsg = result.permissionDenied
|
|
128
|
+
? 'Permission denied while installing globally. Re-run from a terminal with the appropriate permissions (e.g. sudo npm install -g tide-commander@latest), or fix your npm prefix to a user-owned directory.'
|
|
129
|
+
: `npm install exited with code ${result.exitCode}.`;
|
|
130
|
+
send('error', {
|
|
131
|
+
message: errMsg,
|
|
132
|
+
exitCode: result.exitCode,
|
|
133
|
+
permissionDenied: result.permissionDenied,
|
|
134
|
+
suggestedManualCommand: info.suggestedManualCommand,
|
|
135
|
+
});
|
|
136
|
+
send('done', {
|
|
137
|
+
success: false,
|
|
138
|
+
exitCode: result.exitCode,
|
|
139
|
+
newVersion: null,
|
|
140
|
+
requiresRestart: false,
|
|
141
|
+
});
|
|
142
|
+
res.end();
|
|
143
|
+
updateInProgress = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
clearInterval(keepalive);
|
|
148
|
+
const message = err.message;
|
|
149
|
+
log.error(`Self-update failed: ${message}`);
|
|
150
|
+
send('error', { message, permissionDenied: false, suggestedManualCommand: info.suggestedManualCommand });
|
|
151
|
+
send('done', { success: false, exitCode: -1, newVersion: null, requiresRestart: false });
|
|
152
|
+
res.end();
|
|
153
|
+
updateInProgress = false;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
:
|
|
126
|
-
|
|
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
|
-
|
|
148
|
-
if (
|
|
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`
|
|
6
|
-
* - Bitbucket Cloud: `X-Event-Key`
|
|
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
|
-
*
|
|
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
|
|
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
|
|
27
|
-
* GitHub sends `X-GitHub-Event
|
|
28
|
-
* via Node's normalization.
|
|
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';
|