tide-commander 1.140.0 → 1.141.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/CHANGELOG.md +5432 -0
- package/dist/assets/{BossLogsModal-CEShD72G.js → BossLogsModal-DUh9mA0g.js} +1 -1
- package/dist/assets/{BossSpawnModal-D_DbznYG.js → BossSpawnModal-CGEysDmo.js} +1 -1
- package/dist/assets/{ControlsModal-CSV9_1uM.js → ControlsModal-DKlL4BtE.js} +1 -1
- package/dist/assets/{DockerLogsModal-CiygMroW.js → DockerLogsModal-Dmbe01S9.js} +1 -1
- package/dist/assets/{EmbeddedEditor-Dkdulh-K.js → EmbeddedEditor-Bx_zcxVC.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-Diq3kLhl.js → GmailOAuthSetup-CIPQc_WY.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-CeeDd_xu.js → GoogleOAuthSetup-CnOo59V4.js} +1 -1
- package/dist/assets/{IframeModal-C5dbNqKD.js → IframeModal-ByDSCNMW.js} +1 -1
- package/dist/assets/{IntegrationsPanel-DRn7C1MY.js → IntegrationsPanel-CaiJ__1J.js} +2 -2
- package/dist/assets/{LogViewerModal-DOQxrBbw.js → LogViewerModal-C-w4cekL.js} +1 -1
- package/dist/assets/{MonitoringModal-Cwwk7At7.js → MonitoringModal-DIJd8oHM.js} +1 -1
- package/dist/assets/{PM2LogsModal-BBLqAy_X.js → PM2LogsModal-BFelxtQL.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-D02230fP.js → RestoreArchivedAreaModal-BYTl2kIG.js} +1 -1
- package/dist/assets/{Scene2DCanvas-T1mD-NLE.js → Scene2DCanvas-CJgfmvZA.js} +1 -1
- package/dist/assets/{SceneManager-B8CWKy5o.js → SceneManager-dbNZbSKp.js} +1 -1
- package/dist/assets/{SkillsPanel-BcckjcEb.js → SkillsPanel-CwVwPU5t.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Cj8Iu2Wk.js → SlackMultiInstanceSetup-DgjwCx-F.js} +1 -1
- package/dist/assets/{SpawnModal-BvhLamvi.js → SpawnModal-DLdyhgtN.js} +1 -1
- package/dist/assets/{StatisticsModal-CZ5_LEYB.js → StatisticsModal-Dd1yh0YT.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-B5fsamyK.js → SubordinateAssignmentModal-bGplpTfz.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-6lpEA2KC.js → TriggerManagerPanel-BmdbFUJB.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-BrF8p9-9.js → WorkflowEditorPanel-BRGVEv4g.js} +1 -1
- package/dist/assets/{area-logos-N2igqdil.js → area-logos-CPBT7k6a.js} +1 -1
- package/dist/assets/{index-BQHw3dpO.js → index-BIfX5_Xq.js} +6 -6
- package/dist/assets/index-C09uD--W.js +2 -0
- package/dist/assets/{index-DqPObBhQ.js → index-Cd31ErRd.js} +1 -1
- package/dist/assets/{index-C4WbvHfv.js → index-CjdSuvf1.js} +1 -1
- package/dist/assets/{index-C05ImQcq.js → index-Clk0mGIC.js} +1 -1
- package/dist/assets/{index-HX-vahcd.js → index-DIWScVQf.js} +1 -1
- package/dist/assets/{index-DQOQTnz4.js → index-DPPtFIs3.js} +3 -3
- package/dist/assets/{index-egOui-YJ.js → index-DX8uai_5.js} +1 -1
- package/dist/assets/{index-BEjvvTvd.js → index-YDzuES6L.js} +2 -2
- package/dist/assets/main-BCym1dpG.css +1 -0
- package/dist/assets/main-CAE1L3Sb.js +256 -0
- package/dist/assets/{web-Bp9InB_8.js → web-B0Nj3gOp.js} +1 -1
- package/dist/assets/{web-BV4UqfvW.js → web-B5mCc_qm.js} +1 -1
- package/dist/assets/{web-Cbif1aIk.js → web-CaEzTKao.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/data/builtin-skills/create-building.js +7 -2
- package/dist/src/packages/server/data/builtin-skills/http-requests.js +51 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/data/builtin-skills/release-pipeline.js +10 -1
- package/dist/src/packages/server/data/index.js +65 -0
- package/dist/src/packages/server/routes/http-requests.js +148 -0
- package/dist/src/packages/server/routes/index.js +3 -1
- package/dist/src/packages/server/routes/system.js +30 -0
- package/dist/src/packages/server/services/building-service.js +5 -1
- package/dist/src/packages/server/services/claude-usage-service.js +101 -3
- package/dist/src/packages/server/services/http-requests-service.js +450 -0
- package/dist/src/packages/server/services/index.js +1 -0
- package/dist/src/packages/server/websocket/handler.js +2 -1
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +75 -0
- package/dist/src/packages/shared/building-types.js +1 -0
- package/dist/src/packages/shared/http-requests-types.js +7 -0
- package/dist/src/packages/shared/types.js +1 -0
- package/package.json +3 -2
- package/dist/assets/index-HVo8TeN6.js +0 -2
- package/dist/assets/main-CAmYbJhQ.js +0 -243
- package/dist/assets/main-DYAzUs5m.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cQ as t}from"./main-
|
|
1
|
+
import{cQ as t}from"./main-CAE1L3Sb.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cQ as a}from"./main-
|
|
1
|
+
import{cQ as a}from"./main-CAE1L3Sb.js";import{ImpactStyle as i,NotificationType as r}from"./index-YDzuES6L.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cQ as s}from"./main-
|
|
1
|
+
import{cQ as s}from"./main-CAE1L3Sb.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
|
package/dist/index.html
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
|
|
23
23
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
|
|
24
24
|
<title>Tide Commander</title>
|
|
25
|
-
<script type="module" crossorigin src="/assets/main-
|
|
25
|
+
<script type="module" crossorigin src="/assets/main-CAE1L3Sb.js"></script>
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
|
|
28
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
28
|
+
<link rel="stylesheet" crossorigin href="/assets/main-BCym1dpG.css">
|
|
29
29
|
</head>
|
|
30
30
|
<body>
|
|
31
31
|
<div id="app"></div>
|
|
@@ -47,7 +47,7 @@ ${BT}name${BT}, ${BT}type${BT}, and ${BT}position${BT} are always required. Styl
|
|
|
47
47
|
${BT3}typescript
|
|
48
48
|
{
|
|
49
49
|
name: string, // Display name
|
|
50
|
-
type: 'server'|'link'|'database'|'docker'|'monitor'|'folder'|'boss'|'terminal'|'tests',
|
|
50
|
+
type: 'server'|'link'|'database'|'docker'|'monitor'|'folder'|'boss'|'terminal'|'tests'|'http',
|
|
51
51
|
position: { x: number, z: number },
|
|
52
52
|
style?: 'server-rack'|'tower'|'dome'|'pyramid'|'desktop'
|
|
53
53
|
| 'filing-cabinet'|'satellite'|'crystal'|'factory'|'command-center',
|
|
@@ -58,7 +58,7 @@ ${BT3}typescript
|
|
|
58
58
|
docker?: { ... }, // Docker type — see below
|
|
59
59
|
database?: { connections: [...] }, // Database type
|
|
60
60
|
terminal?: { ... }, // Terminal type
|
|
61
|
-
folderPath?: string, // Folder + tests types — required
|
|
61
|
+
folderPath?: string, // Folder + tests + http types — required
|
|
62
62
|
urls?: [{ label, url }], // Link type — required
|
|
63
63
|
commands?: { start, stop, restart, healthCheck, logs }, // Non-PM2 server custom commands
|
|
64
64
|
subordinateBuildingIds?: string[], // Boss type
|
|
@@ -221,6 +221,11 @@ Requires ${BT}ttyd${BT} installed; ${BT}saveSession: true${BT} also needs ${BT}t
|
|
|
221
221
|
- ${BT}{"name":"Docs","type":"link","style":"tower","position":{"x":10,"z":0},"urls":[{"label":"Internal Wiki","url":"https://wiki.example.com"}]}${BT}
|
|
222
222
|
- ${BT}{"name":"Host Metrics","type":"monitor","style":"satellite","position":{"x":12,"z":4}}${BT}
|
|
223
223
|
|
|
224
|
+
### Tests / HTTP requests
|
|
225
|
+
|
|
226
|
+
- Tests browser (scan + run a folder's test suite): ${BT}{"name":"Core Tests","type":"tests","style":"dome","position":{"x":6,"z":3},"folderPath":"/home/user/projects/core"}${BT}
|
|
227
|
+
- HTTP request tests (IntelliJ-style .http file runner; picks up ${BT}http-client.env.json${BT} / ${BT}http-client.private.env.json${BT} environments in the folder): ${BT}{"name":"Core API Requests","type":"http","style":"satellite","position":{"x":7,"z":3},"folderPath":"/home/user/projects/core/http-requests"}${BT}
|
|
228
|
+
|
|
224
229
|
### Boss
|
|
225
230
|
|
|
226
231
|
Subordinates must already exist — get their IDs from ${BT}GET /api/buildings${BT} first.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const httpRequests = {
|
|
2
|
+
slug: 'http-requests',
|
|
3
|
+
name: 'HTTP Request Tests',
|
|
4
|
+
description: 'Browse and fire IntelliJ-style .http request files via the Tide Commander http-requests API; results render as rich cards in the terminal (method, status, timing, body preview).',
|
|
5
|
+
allowedTools: ['Bash(curl:*)', 'Bash(jq:*)'],
|
|
6
|
+
// Not assigned to anyone by default — enable it per agent/class in the Skills panel.
|
|
7
|
+
assignedAgentClasses: [],
|
|
8
|
+
content: `# HTTP Request Tests
|
|
9
|
+
|
|
10
|
+
Run requests from IntelliJ-style \`.http\` / \`.rest\` files through \`/api/http-requests/*\` (never by hand-crafting the HTTP call yourself) so each result is persisted to the building's history and renders as a **rich card** in the user's terminal — method, status pill, timing, size and a body preview instead of raw JSON.
|
|
11
|
+
|
|
12
|
+
Use this when asked to fire/test HTTP requests from a project's \`.http\` files, smoke-test API endpoints defined there, or report on past request runs. The folder usually comes from an \`http\`-type building — find it with \`GET /api/buildings\` (look for \`"type":"http"\`, use its \`folderPath\`).
|
|
13
|
+
|
|
14
|
+
All calls use the API Calling Convention scaffolding (host, \`-H "X-Auth-Token: ..."\`). No exclamation marks in commands.
|
|
15
|
+
|
|
16
|
+
## 1. Scan — list files, requests and environments
|
|
17
|
+
|
|
18
|
+
\`POST /api/http-requests/scan\`
|
|
19
|
+
\`\`\`json
|
|
20
|
+
{"path":"/abs/path/to/http-folder"}
|
|
21
|
+
\`\`\`
|
|
22
|
+
Returns every parsed file with its requests (0-based \`index\`, \`name\`, \`method\`, \`url\`, \`variables\`) plus \`environments\` (from \`http-client.env.json\` / \`http-client.private.env.json\`) and \`envFiles\`. Pipe through \`jq\` to keep it short, e.g. \`jq '{environments, files: [.files[] | {relFile, requests: [.requests[] | {index, name, method}]}]}'\`.
|
|
23
|
+
|
|
24
|
+
## 2. Run one request
|
|
25
|
+
|
|
26
|
+
\`POST /api/http-requests/run\`
|
|
27
|
+
\`\`\`json
|
|
28
|
+
{"agentId":"YOUR_AGENT_ID","path":"/abs/path/to/http-folder","relFile":"payment-order.http","requestIndex":2,"env":"dev"}
|
|
29
|
+
\`\`\`
|
|
30
|
+
- \`agentId\` (IMPORTANT) — your agent ID from the system prompt. With it, a live card appears in the user's terminal under your curl the moment the request fires (spinner → method/status/time/body, with Re-run and copy-curl buttons). Without it, nothing renders.
|
|
31
|
+
- \`requestIndex\` — the request's 0-based \`index\` from the scan.
|
|
32
|
+
- \`env\` (optional but usually required) — environment name for \`{{variable}}\` resolution; omit only when the file has no variables. \`unresolvedVariables\` in the response tells you what was missing.
|
|
33
|
+
|
|
34
|
+
Synchronous: the response IS the result (\`ok\`, \`status\`, \`timeMs\`, \`sizeBytes\`, \`headers\`, \`body\`, resolved \`request\`, \`runId\`). 60s timeout, 2 MB body cap.
|
|
35
|
+
|
|
36
|
+
Silence the response with \`-o /dev/null\` if you don't need to inspect it (the card already shows everything to the user), or print it as-is (plain \`curl -s\`, no jq filter) — printed run JSON also renders as a card. To READ a field yourself, fetch \`GET /runs/RUN_ID\` afterwards and jq that.
|
|
37
|
+
|
|
38
|
+
## 3. Run several / "run all in a file"
|
|
39
|
+
|
|
40
|
+
Run each request as its OWN curl command (one Bash call per request, agentId included) so every run gets its own live card in the terminal. Avoid shell for-loops — a loop is a single Bash line, so only the last run's card would attach to it. Do not parallelize.
|
|
41
|
+
|
|
42
|
+
## 4. History
|
|
43
|
+
|
|
44
|
+
\`GET /api/http-requests/history?folder=/abs/path&limit=20\` — recent executed requests for that folder (newest first). Printing this JSON also renders as a card (compact list of runs). \`GET /api/http-requests/runs/RUN_ID\` returns one stored run with the full request/response detail.
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
- A non-2xx status or \`"ok":false\` (connection refused, timeout) is a **normal result to report**, not an API error — summarize which requests passed/failed and why.
|
|
48
|
+
- Prefer the building's configured folder; for ad-hoc paths anything under the home directory works.
|
|
49
|
+
- Requests hit real endpoints. Fire GETs freely; for POST/PUT/PATCH/DELETE against non-test systems, confirm with the user first unless they explicitly asked.
|
|
50
|
+
- After a batch, finish with a short written summary (counts + failures); the cards complement it, they don't replace it.`,
|
|
51
|
+
};
|
|
@@ -29,6 +29,7 @@ import { workflowBuilder } from './workflow-builder.js';
|
|
|
29
29
|
import { backupRestore } from './backup-restore.js';
|
|
30
30
|
import { browserControl } from './browser-control.js';
|
|
31
31
|
import { runTests } from './run-tests.js';
|
|
32
|
+
import { httpRequests } from './http-requests.js';
|
|
32
33
|
/**
|
|
33
34
|
* All built-in skills that ship with Tide Commander
|
|
34
35
|
*/
|
|
@@ -57,6 +58,7 @@ export const BUILTIN_SKILLS = [
|
|
|
57
58
|
backupRestore,
|
|
58
59
|
browserControl,
|
|
59
60
|
runTests,
|
|
61
|
+
httpRequests,
|
|
60
62
|
];
|
|
61
63
|
/**
|
|
62
64
|
* Get the ID for a built-in skill based on its slug
|
|
@@ -62,6 +62,13 @@ git pull --rebase origin $(git branch --show-current)
|
|
|
62
62
|
\`\`\`
|
|
63
63
|
On conflicts: STOP immediately and report. Do NOT auto-resolve.
|
|
64
64
|
|
|
65
|
+
Verify the changelog packaging contract (fail fast — before doing any build/publish work):
|
|
66
|
+
|
|
67
|
+
\`\`\`bash
|
|
68
|
+
npm pkg get files | grep -q '"CHANGELOG.md"' || echo "MISSING: CHANGELOG.md not in package.json files"
|
|
69
|
+
\`\`\`
|
|
70
|
+
\`CHANGELOG.md\` MUST be listed in package.json \`files\`. It ships in the npm tarball and is served by the server at \`GET /api/system/changelog\`; the in-app changelog (post-update banner + Settings → About) reads this local packaged file instead of the GitHub API, which 403'd on rate limits (60 req/hour). If the check prints \`MISSING\`: STOP and report — do NOT publish, or the in-app changelog breaks.
|
|
71
|
+
|
|
65
72
|
### Phase 2: Quality Gates (3 parallel sub-agents)
|
|
66
73
|
|
|
67
74
|
Spawn all 3 Agent calls in a single response, each using the curl template above with its command, checking output and exitCode, and instructed not to fix any issues:
|
|
@@ -105,7 +112,7 @@ npm version <patch|minor|major> --no-git-tag-version
|
|
|
105
112
|
git log --oneline $(git describe --tags --abbrev=0 2>/dev/null || echo "HEAD~20")..HEAD
|
|
106
113
|
\`\`\`
|
|
107
114
|
|
|
108
|
-
Add the new version entry at the top of \`CHANGELOG.md\` (below the header), Keep a Changelog format. Only include sections that have entries; write concise, user-facing descriptions.
|
|
115
|
+
Add the new version entry at the top of \`CHANGELOG.md\` (directly below the \`# Changelog\` header), Keep a Changelog format. Only include sections that have entries; write concise, user-facing descriptions. The changelog is a committed, packaged (package.json \`files\`), server-served artifact — it MUST be updated every release so the bundled file stays current.
|
|
109
116
|
|
|
110
117
|
\`\`\`markdown
|
|
111
118
|
## [X.Y.Z] - YYYY-MM-DD
|
|
@@ -123,6 +130,8 @@ Add the new version entry at the top of \`CHANGELOG.md\` (below the header), Kee
|
|
|
123
130
|
- (from \`remove:\` or \`delete:\` commits)
|
|
124
131
|
\`\`\`
|
|
125
132
|
|
|
133
|
+
CRITICAL — keep the per-version header EXACTLY \`## [X.Y.Z] - YYYY-MM-DD\` (Keep a Changelog): one header per release, newest at the top. The in-app changelog modal extracts a single release by matching the line \`## [X.Y.Z]\`; any other header format breaks the per-version view (the full-changelog view still works). Do NOT drop the \`[\` \`]\` brackets, the version number, or change the \` - YYYY-MM-DD\` suffix.
|
|
134
|
+
|
|
126
135
|
### Phase 6: Commit, Tag, Push (run yourself — sequential git ops)
|
|
127
136
|
|
|
128
137
|
\`\`\`bash
|
|
@@ -923,3 +923,68 @@ export function saveTestRun(run) {
|
|
|
923
923
|
log.error(' Failed to save test run:', err);
|
|
924
924
|
}
|
|
925
925
|
}
|
|
926
|
+
// ============================================================================
|
|
927
|
+
// HTTP Run History (persisted to http-runs/ — one file per run + an index)
|
|
928
|
+
// Mirrors the test-run history above for the "http" (.http requests) building.
|
|
929
|
+
// ============================================================================
|
|
930
|
+
const HTTP_RUNS_DIR = path.join(DATA_DIR, 'http-runs');
|
|
931
|
+
const HTTP_RUNS_INDEX_FILE = path.join(HTTP_RUNS_DIR, 'index.json');
|
|
932
|
+
const MAX_HTTP_RUNS = 300;
|
|
933
|
+
function ensureHttpRunsDir() {
|
|
934
|
+
if (!fs.existsSync(HTTP_RUNS_DIR)) {
|
|
935
|
+
fs.mkdirSync(HTTP_RUNS_DIR, { recursive: true });
|
|
936
|
+
log.log(` Created http runs directory: ${HTTP_RUNS_DIR}`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function getHttpRunFile(runId) {
|
|
940
|
+
return path.join(HTTP_RUNS_DIR, `${runId}.json`);
|
|
941
|
+
}
|
|
942
|
+
function summarizeHttpRun(run) {
|
|
943
|
+
const { result: _result, ...summary } = run;
|
|
944
|
+
return summary;
|
|
945
|
+
}
|
|
946
|
+
/** List recent http runs (newest first); optionally scoped to one folder. */
|
|
947
|
+
export function loadHttpRunSummaries(limit = 50, folder) {
|
|
948
|
+
ensureHttpRunsDir();
|
|
949
|
+
const data = safeReadJsonSync(HTTP_RUNS_INDEX_FILE, 'Http runs index');
|
|
950
|
+
let runs = data?.runs || [];
|
|
951
|
+
if (folder)
|
|
952
|
+
runs = runs.filter((r) => r.folder === folder);
|
|
953
|
+
return limit > 0 ? runs.slice(0, limit) : runs;
|
|
954
|
+
}
|
|
955
|
+
/** Read a single stored http run (full request/response detail) by id. */
|
|
956
|
+
export function loadHttpRun(runId) {
|
|
957
|
+
return safeReadJsonSync(getHttpRunFile(runId), `Http run ${runId}`);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Persist an executed request: write its full file, prepend a summary to the
|
|
961
|
+
* index, cap at MAX_HTTP_RUNS, prune per-run files that fell out.
|
|
962
|
+
*/
|
|
963
|
+
export function saveHttpRun(run) {
|
|
964
|
+
ensureHttpRunsDir();
|
|
965
|
+
try {
|
|
966
|
+
atomicWriteJsonSync(getHttpRunFile(run.runId), run);
|
|
967
|
+
const existing = loadHttpRunSummaries(0).filter((r) => r.runId !== run.runId);
|
|
968
|
+
const runs = [summarizeHttpRun(run), ...existing];
|
|
969
|
+
const kept = runs.slice(0, MAX_HTTP_RUNS);
|
|
970
|
+
const dropped = runs.slice(MAX_HTTP_RUNS);
|
|
971
|
+
atomicWriteJsonSync(HTTP_RUNS_INDEX_FILE, {
|
|
972
|
+
runs: kept,
|
|
973
|
+
savedAt: Date.now(),
|
|
974
|
+
version: '1.0.0',
|
|
975
|
+
});
|
|
976
|
+
for (const d of dropped) {
|
|
977
|
+
try {
|
|
978
|
+
const f = getHttpRunFile(d.runId);
|
|
979
|
+
if (fs.existsSync(f))
|
|
980
|
+
fs.unlinkSync(f);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// best-effort prune
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
log.error(' Failed to save http run:', err);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Request Tests Routes (IntelliJ-style .http files)
|
|
3
|
+
*
|
|
4
|
+
* REST API for the "http" building: scan a folder for .http files and fire
|
|
5
|
+
* individual requests. Runs are synchronous — a single HTTP request resolves
|
|
6
|
+
* quickly (60s server-side timeout), so no runId/WebSocket plumbing is needed.
|
|
7
|
+
*/
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
import { httpRequestsService } from '../services/index.js';
|
|
10
|
+
import { createLogger, generateId } from '../utils/index.js';
|
|
11
|
+
import { saveHttpRun, loadHttpRun, loadHttpRunSummaries } from '../data/index.js';
|
|
12
|
+
const log = createLogger('HttpRequestsRoute');
|
|
13
|
+
const router = Router();
|
|
14
|
+
// Store for broadcasting via WebSocket (wired from the websocket handler).
|
|
15
|
+
let broadcastFn = null;
|
|
16
|
+
export function setBroadcast(fn) {
|
|
17
|
+
broadcastFn = fn;
|
|
18
|
+
}
|
|
19
|
+
function broadcast(message) {
|
|
20
|
+
broadcastFn?.(message);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* POST /api/http-requests/scan - Parse every .http/.rest file under a folder.
|
|
24
|
+
* Body: { path: string }
|
|
25
|
+
*/
|
|
26
|
+
router.post('/scan', (req, res) => {
|
|
27
|
+
const { path: targetPath } = req.body;
|
|
28
|
+
if (!targetPath) {
|
|
29
|
+
res.status(400).json({ error: 'Missing required field: path' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!httpRequestsService.isSafeFolder(targetPath)) {
|
|
33
|
+
res.status(400).json({ error: 'Refusing to scan folders outside your home directory.' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.json(httpRequestsService.scanFolder(targetPath));
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* POST /api/http-requests/run - Execute one request from a .http file.
|
|
40
|
+
* Body: { path, relFile, requestIndex, env? } — responds with HttpRunResult
|
|
41
|
+
* once the request completes (or fails / times out).
|
|
42
|
+
*/
|
|
43
|
+
router.post('/run', async (req, res) => {
|
|
44
|
+
const { path: targetPath, relFile, requestIndex, env, agentId } = req.body;
|
|
45
|
+
if (!targetPath || !relFile || typeof requestIndex !== 'number') {
|
|
46
|
+
res.status(400).json({ error: 'Missing required fields: path, relFile, requestIndex' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!httpRequestsService.isSafeFolder(targetPath)) {
|
|
50
|
+
res.status(400).json({ error: 'Refusing to run requests outside your home directory.' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const startAgentId = typeof agentId === 'string' && agentId ? agentId : undefined;
|
|
54
|
+
const runId = generateId();
|
|
55
|
+
try {
|
|
56
|
+
// Announce the run when an agent fired it, so its terminal shows a live
|
|
57
|
+
// inline card immediately (spinner → result), like test runs.
|
|
58
|
+
if (startAgentId) {
|
|
59
|
+
const item = httpRequestsService.peekRequest(targetPath, relFile, requestIndex);
|
|
60
|
+
broadcast({
|
|
61
|
+
type: 'http_run_started',
|
|
62
|
+
payload: {
|
|
63
|
+
runId,
|
|
64
|
+
agentId: startAgentId,
|
|
65
|
+
folder: targetPath,
|
|
66
|
+
relFile,
|
|
67
|
+
requestIndex,
|
|
68
|
+
requestName: item?.name || `${relFile}#${requestIndex}`,
|
|
69
|
+
method: item?.method || 'GET',
|
|
70
|
+
url: item?.url || '',
|
|
71
|
+
env,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const result = await httpRequestsService.executeRequest(targetPath, relFile, requestIndex, env);
|
|
76
|
+
log.log(`${result.request.method} ${result.request.url} → ` +
|
|
77
|
+
(result.ok ? `${result.status} in ${result.timeMs}ms` : `error: ${result.error}`));
|
|
78
|
+
// Persist to history (best-effort — the response never waits on failures
|
|
79
|
+
// beyond the synchronous write).
|
|
80
|
+
const stored = {
|
|
81
|
+
runId,
|
|
82
|
+
folder: targetPath,
|
|
83
|
+
relFile,
|
|
84
|
+
requestIndex,
|
|
85
|
+
requestName: result.requestName || `${result.request.method} ${result.request.url}`,
|
|
86
|
+
method: result.request.method,
|
|
87
|
+
url: result.request.url,
|
|
88
|
+
env,
|
|
89
|
+
ok: result.ok,
|
|
90
|
+
status: result.status,
|
|
91
|
+
timeMs: result.timeMs,
|
|
92
|
+
sizeBytes: result.sizeBytes,
|
|
93
|
+
error: result.error,
|
|
94
|
+
finishedAt: Date.now(),
|
|
95
|
+
result,
|
|
96
|
+
};
|
|
97
|
+
saveHttpRun(stored);
|
|
98
|
+
if (startAgentId) {
|
|
99
|
+
broadcast({
|
|
100
|
+
type: 'http_run_completed',
|
|
101
|
+
payload: { runId, agentId: startAgentId, result },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
res.json({ ...result, runId });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// Close the inline card on hard failures too (parse error, missing file).
|
|
108
|
+
if (startAgentId) {
|
|
109
|
+
broadcast({
|
|
110
|
+
type: 'http_run_completed',
|
|
111
|
+
payload: {
|
|
112
|
+
runId,
|
|
113
|
+
agentId: startAgentId,
|
|
114
|
+
result: {
|
|
115
|
+
ok: false,
|
|
116
|
+
request: { method: 'GET', url: '', headers: [] },
|
|
117
|
+
timeMs: 0,
|
|
118
|
+
error: err?.message || 'Failed to run request',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
res.status(400).json({ error: err?.message || 'Failed to run request' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* GET /api/http-requests/history - Recent executed requests (newest first).
|
|
128
|
+
* Query: ?folder=<abs path> scopes to one building's folder; ?limit=N (default
|
|
129
|
+
* 50, max 200). Summaries only — fetch /runs/:runId for the full detail.
|
|
130
|
+
*/
|
|
131
|
+
router.get('/history', (req, res) => {
|
|
132
|
+
const raw = parseInt(req.query.limit, 10);
|
|
133
|
+
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
134
|
+
const folder = typeof req.query.folder === 'string' && req.query.folder ? req.query.folder : undefined;
|
|
135
|
+
res.json({ runs: loadHttpRunSummaries(limit, folder) });
|
|
136
|
+
});
|
|
137
|
+
/**
|
|
138
|
+
* GET /api/http-requests/runs/:runId - Full stored run (request + response).
|
|
139
|
+
*/
|
|
140
|
+
router.get('/runs/:runId', (req, res) => {
|
|
141
|
+
const run = loadHttpRun(req.params.runId);
|
|
142
|
+
if (!run) {
|
|
143
|
+
res.status(404).json({ error: 'Run not found' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
res.json(run);
|
|
147
|
+
});
|
|
148
|
+
export default router;
|
|
@@ -11,6 +11,7 @@ import agentPromptRouter from './agent-prompt.js';
|
|
|
11
11
|
import notificationsRouter, { setBroadcast as setNotificationBroadcast } from './notifications.js';
|
|
12
12
|
import execRouter, { setBroadcast as setExecBroadcast } from './exec.js';
|
|
13
13
|
import testsRouter, { setBroadcast as setTestsBroadcast } from './tests.js';
|
|
14
|
+
import httpRequestsRouter, { setBroadcast as setHttpRequestsBroadcast } from './http-requests.js';
|
|
14
15
|
import focusAgentRouter, { setBroadcast as setFocusAgentBroadcast } from './focus-agent.js';
|
|
15
16
|
import customModelsRouter from './custom-models.js';
|
|
16
17
|
import configRouter from './config.js';
|
|
@@ -44,6 +45,7 @@ router.use('/folders', foldersRouter);
|
|
|
44
45
|
router.use('/notify', notificationsRouter);
|
|
45
46
|
router.use('/exec', execRouter);
|
|
46
47
|
router.use('/tests', testsRouter);
|
|
48
|
+
router.use('/http-requests', httpRequestsRouter);
|
|
47
49
|
router.use('/focus-agent', focusAgentRouter);
|
|
48
50
|
router.use('/custom-models', customModelsRouter);
|
|
49
51
|
router.use('/tts', ttsRouter);
|
|
@@ -83,5 +85,5 @@ router.use('/', permissionsRouter);
|
|
|
83
85
|
// Agent prompt routes — root-level: /api/agent-prompt and /api/agent-prompt/:id/respond
|
|
84
86
|
router.use('/', agentPromptRouter);
|
|
85
87
|
// Export the broadcast setters for WebSocket handler to use
|
|
86
|
-
export { setNotificationBroadcast, setExecBroadcast, setTestsBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast };
|
|
88
|
+
export { setNotificationBroadcast, setExecBroadcast, setTestsBroadcast, setHttpRequestsBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast };
|
|
87
89
|
export default router;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Endpoints for inspecting / updating the running Tide Commander install.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
import { createLogger } from '../utils/logger.js';
|
|
7
9
|
import { fetchLatestNpmVersion, getVersionRelation } from '../../shared/version.js';
|
|
8
10
|
import { getInstallInfo, isAutoUpdateSupported, runNpmGlobalUpdate, schedulePostUpdateRestart, } from '../services/self-update-service.js';
|
|
@@ -45,6 +47,34 @@ router.get('/install-info', async (_req, res) => {
|
|
|
45
47
|
res.status(500).json({ error: message });
|
|
46
48
|
}
|
|
47
49
|
});
|
|
50
|
+
/**
|
|
51
|
+
* GET /api/system/changelog
|
|
52
|
+
*
|
|
53
|
+
* Serves the bundled CHANGELOG.md (Keep a Changelog markdown) so the UI can
|
|
54
|
+
* render release notes locally — no GitHub API, no rate limits, works offline
|
|
55
|
+
* and always matches the installed version.
|
|
56
|
+
*/
|
|
57
|
+
router.get('/changelog', (_req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const installRoot = getInstallInfo().installRoot;
|
|
60
|
+
if (!installRoot) {
|
|
61
|
+
res.status(404).json({ error: 'Could not locate install root.' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const changelogPath = path.join(installRoot, 'CHANGELOG.md');
|
|
65
|
+
if (!fs.existsSync(changelogPath)) {
|
|
66
|
+
res.status(404).json({ error: 'CHANGELOG.md not found.' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const content = fs.readFileSync(changelogPath, 'utf8');
|
|
70
|
+
res.type('text/markdown').send(content);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const message = err.message;
|
|
74
|
+
log.error(`Failed to read changelog: ${message}`);
|
|
75
|
+
res.status(500).json({ error: message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
48
78
|
/**
|
|
49
79
|
* POST /api/system/self-update
|
|
50
80
|
*
|
|
@@ -1088,7 +1088,7 @@ export async function handleBuildingSync(newBuildings, _broadcast) {
|
|
|
1088
1088
|
// Validation & CRUD (Phase 1 - REST API support)
|
|
1089
1089
|
// ============================================================================
|
|
1090
1090
|
const VALID_TYPES = new Set([
|
|
1091
|
-
'server', 'link', 'database', 'docker', 'monitor', 'folder', 'boss', 'terminal', 'tests',
|
|
1091
|
+
'server', 'link', 'database', 'docker', 'monitor', 'folder', 'boss', 'terminal', 'tests', 'http',
|
|
1092
1092
|
]);
|
|
1093
1093
|
const VALID_STYLES = new Set([
|
|
1094
1094
|
'server-rack', 'tower', 'dome', 'pyramid', 'desktop',
|
|
@@ -1113,6 +1113,7 @@ const DEFAULT_STYLE_BY_TYPE = {
|
|
|
1113
1113
|
boss: 'command-center',
|
|
1114
1114
|
terminal: 'desktop',
|
|
1115
1115
|
tests: 'dome',
|
|
1116
|
+
http: 'satellite',
|
|
1116
1117
|
};
|
|
1117
1118
|
function generateBuildingId(name) {
|
|
1118
1119
|
const slug = name
|
|
@@ -1203,6 +1204,9 @@ export function validateBuilding(input) {
|
|
|
1203
1204
|
if (input.type === 'tests' && !input.folderPath) {
|
|
1204
1205
|
errors.push('folderPath is required for tests buildings (the folder whose tests it browses)');
|
|
1205
1206
|
}
|
|
1207
|
+
if (input.type === 'http' && !input.folderPath) {
|
|
1208
|
+
errors.push('folderPath is required for http buildings (the folder containing the .http request files)');
|
|
1209
|
+
}
|
|
1206
1210
|
if (input.type === 'link' && (!input.urls || input.urls.length === 0)) {
|
|
1207
1211
|
errors.push('urls is required (non-empty array) for link buildings');
|
|
1208
1212
|
}
|
|
@@ -25,6 +25,18 @@ const STATS_CACHE_PATH = path.join(os.homedir(), '.claude', 'stats-cache.json');
|
|
|
25
25
|
const CREDENTIALS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
26
26
|
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
27
27
|
const OAUTH_USAGE_TIMEOUT_MS = 5_000;
|
|
28
|
+
// --- Local throttling of the Anthropic OAuth usage endpoint ------------------
|
|
29
|
+
//
|
|
30
|
+
// The rate-limit gauges are ACCOUNT-WIDE — identical no matter which agent asks —
|
|
31
|
+
// so a single cached value serves every agent and every client poll. Before this
|
|
32
|
+
// throttle, each hovered agent chip / open usage panel fired its own request to
|
|
33
|
+
// Anthropic and the endpoint started returning 429. We (1) reuse a good result
|
|
34
|
+
// for a TTL, (2) dedupe concurrent fetches into ONE network request
|
|
35
|
+
// (single-flight), and (3) back off hard when Anthropic rate-limits us — honoring
|
|
36
|
+
// its `Retry-After` header when present.
|
|
37
|
+
const RATE_LIMIT_CACHE_TTL_MS = 60_000; // reuse a good result for a minute
|
|
38
|
+
const RATE_LIMIT_429_BACKOFF_MS = 5 * 60_000; // after a 429, don't retry for 5 min
|
|
39
|
+
const RATE_LIMIT_ERROR_BACKOFF_MS = 30_000; // after any other failure, cool down 30s
|
|
28
40
|
function readStatsCache() {
|
|
29
41
|
try {
|
|
30
42
|
if (!fs.existsSync(STATS_CACHE_PATH))
|
|
@@ -279,13 +291,29 @@ function parseRateLimitWindow(value) {
|
|
|
279
291
|
resetsAt: window.resets_at,
|
|
280
292
|
};
|
|
281
293
|
}
|
|
294
|
+
// `Retry-After` is either delta-seconds or an HTTP date; return ms-from-now.
|
|
295
|
+
function parseRetryAfterMs(headerValue) {
|
|
296
|
+
if (!headerValue)
|
|
297
|
+
return undefined;
|
|
298
|
+
const seconds = Number(headerValue);
|
|
299
|
+
if (Number.isFinite(seconds))
|
|
300
|
+
return Math.max(0, seconds * 1000);
|
|
301
|
+
const dateMs = Date.parse(headerValue);
|
|
302
|
+
if (Number.isFinite(dateMs))
|
|
303
|
+
return Math.max(0, dateMs - Date.now());
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
282
306
|
/**
|
|
283
307
|
* Fetch the live session/weekly rate-limit gauges — the data behind the
|
|
284
308
|
* progress bars in the CLI's `/usage` panel — using the OAuth token the CLI
|
|
285
309
|
* keeps in `~/.claude/.credentials.json`. Returns the gauges or an error
|
|
286
310
|
* string (never throws); the caller folds either into the snapshot.
|
|
311
|
+
*
|
|
312
|
+
* This is the RAW network call. Go through `getClaudeRateLimits()` instead —
|
|
313
|
+
* it caches, dedupes, and rate-limits calls to this so we don't hammer (and get
|
|
314
|
+
* 429'd by) the Anthropic endpoint.
|
|
287
315
|
*/
|
|
288
|
-
async function
|
|
316
|
+
async function fetchClaudeRateLimitsFromApi() {
|
|
289
317
|
let accessToken;
|
|
290
318
|
try {
|
|
291
319
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
@@ -315,7 +343,12 @@ async function fetchClaudeRateLimits() {
|
|
|
315
343
|
signal: AbortSignal.timeout(OAUTH_USAGE_TIMEOUT_MS),
|
|
316
344
|
});
|
|
317
345
|
if (!response.ok) {
|
|
318
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
rateLimits: null,
|
|
348
|
+
error: `Anthropic usage endpoint returned ${response.status}`,
|
|
349
|
+
status: response.status,
|
|
350
|
+
retryAfterMs: parseRetryAfterMs(response.headers.get('retry-after')),
|
|
351
|
+
};
|
|
319
352
|
}
|
|
320
353
|
const body = await response.json();
|
|
321
354
|
return {
|
|
@@ -326,6 +359,7 @@ async function fetchClaudeRateLimits() {
|
|
|
326
359
|
sevenDaySonnet: parseRateLimitWindow(body.seven_day_sonnet),
|
|
327
360
|
},
|
|
328
361
|
error: null,
|
|
362
|
+
status: response.status,
|
|
329
363
|
};
|
|
330
364
|
}
|
|
331
365
|
catch (err) {
|
|
@@ -334,6 +368,70 @@ async function fetchClaudeRateLimits() {
|
|
|
334
368
|
return { rateLimits: null, error: `Could not reach Anthropic usage endpoint (${reason})` };
|
|
335
369
|
}
|
|
336
370
|
}
|
|
371
|
+
// Account-wide, so a single cache line serves every agent/client. Never keyed.
|
|
372
|
+
let rateLimitCache = null;
|
|
373
|
+
// Last successful gauges, kept so we can serve them (stale) while backing off —
|
|
374
|
+
// the panel keeps showing numbers instead of going blank after a transient 429.
|
|
375
|
+
let lastGoodRateLimits = null;
|
|
376
|
+
// In-flight fetch, so N concurrent callers share ONE network request.
|
|
377
|
+
let rateLimitInFlight = null;
|
|
378
|
+
/**
|
|
379
|
+
* Cached, single-flight, backoff-aware accessor for the rate-limit gauges.
|
|
380
|
+
*
|
|
381
|
+
* Guarantees at most one request to the Anthropic usage endpoint per TTL window
|
|
382
|
+
* (or per backoff window after a failure), no matter how many agents or clients
|
|
383
|
+
* poll `/api/agents/:id/usage` concurrently. On 429 we honor `Retry-After` (or
|
|
384
|
+
* fall back to a 5-minute cooldown); on any other failure we cool down briefly.
|
|
385
|
+
* `resetClaudeRateLimitCache()` clears the cache for tests.
|
|
386
|
+
*/
|
|
387
|
+
async function getClaudeRateLimits() {
|
|
388
|
+
const now = Date.now();
|
|
389
|
+
// Serve the cached value while it's still valid (covers both the TTL after a
|
|
390
|
+
// success AND the backoff window after a failure).
|
|
391
|
+
if (rateLimitCache && now < rateLimitCache.validUntil) {
|
|
392
|
+
return { rateLimits: rateLimitCache.rateLimits, error: rateLimitCache.error };
|
|
393
|
+
}
|
|
394
|
+
// Single-flight: concurrent callers await the same request rather than each
|
|
395
|
+
// firing their own (which is exactly what triggered the 429s).
|
|
396
|
+
if (rateLimitInFlight)
|
|
397
|
+
return rateLimitInFlight;
|
|
398
|
+
rateLimitInFlight = (async () => {
|
|
399
|
+
const result = await fetchClaudeRateLimitsFromApi();
|
|
400
|
+
let ttl = RATE_LIMIT_CACHE_TTL_MS;
|
|
401
|
+
if (result.rateLimits) {
|
|
402
|
+
lastGoodRateLimits = result.rateLimits;
|
|
403
|
+
}
|
|
404
|
+
else if (result.status === 429) {
|
|
405
|
+
// Respect Anthropic's own Retry-After when it sends one; never shorter
|
|
406
|
+
// than the normal TTL so a stray header can't reopen the floodgates.
|
|
407
|
+
ttl = Math.max(result.retryAfterMs ?? RATE_LIMIT_429_BACKOFF_MS, RATE_LIMIT_CACHE_TTL_MS);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
ttl = RATE_LIMIT_ERROR_BACKOFF_MS;
|
|
411
|
+
}
|
|
412
|
+
// During a failure/backoff, still surface the last good gauges (stale) so
|
|
413
|
+
// the panel keeps showing numbers alongside the error string.
|
|
414
|
+
const entry = {
|
|
415
|
+
rateLimits: result.rateLimits ?? lastGoodRateLimits,
|
|
416
|
+
error: result.error,
|
|
417
|
+
validUntil: Date.now() + ttl,
|
|
418
|
+
};
|
|
419
|
+
rateLimitCache = entry;
|
|
420
|
+
return { rateLimits: entry.rateLimits, error: entry.error };
|
|
421
|
+
})();
|
|
422
|
+
try {
|
|
423
|
+
return await rateLimitInFlight;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
rateLimitInFlight = null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Test helper: drop the cached rate-limit result and backoff state. */
|
|
430
|
+
export function resetClaudeRateLimitCache() {
|
|
431
|
+
rateLimitCache = null;
|
|
432
|
+
lastGoodRateLimits = null;
|
|
433
|
+
rateLimitInFlight = null;
|
|
434
|
+
}
|
|
337
435
|
function todayString() {
|
|
338
436
|
const d = new Date();
|
|
339
437
|
const yyyy = d.getFullYear();
|
|
@@ -462,7 +560,7 @@ export async function buildClaudeUsageSnapshot(agent) {
|
|
|
462
560
|
// transcript scan alongside the rate-limit fetch — they're independent I/O.
|
|
463
561
|
const [liveToday, { rateLimits, error: rateLimitsError }] = await Promise.all([
|
|
464
562
|
cachedToday ? Promise.resolve(null) : computeTodayActivity(),
|
|
465
|
-
|
|
563
|
+
getClaudeRateLimits(),
|
|
466
564
|
]);
|
|
467
565
|
const today = cachedToday ?? liveToday;
|
|
468
566
|
const recentDays = (liveToday ? [liveToday, ...sortedDesc] : sortedDesc).slice(0, 8);
|