tide-commander 1.65.0 → 1.66.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 (47) hide show
  1. package/dist/assets/{BossLogsModal-U4mcLIP3.js → BossLogsModal-D6nnEtFf.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-CiFvgaQ1.js → BossSpawnModal-DmPw2xym.js} +1 -1
  3. package/dist/assets/{ControlsModal-Bz4EQ0ii.js → ControlsModal-OEZddGmt.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CSMh4uRZ.js → DockerLogsModal-CzpJKdxd.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ecjZKKkD.js → EmbeddedEditor-Dh9neVWW.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-pFwSGVxa.js → GmailOAuthSetup-FtYHQDDw.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-CPvUA9JE.js → GoogleOAuthSetup-reYSDcFV.js} +1 -1
  8. package/dist/assets/{IframeModal-DcTPyjLy.js → IframeModal-DbW9WCx6.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-DHdp8HO-.js → IntegrationsPanel-DooKtzpO.js} +2 -2
  10. package/dist/assets/{LogViewerModal-QWGgGmiP.js → LogViewerModal-BamqTQzc.js} +1 -1
  11. package/dist/assets/{MonitoringModal-C8cfJlg5.js → MonitoringModal-DiOT_xpJ.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-uUu_bTeC.js → PM2LogsModal-C4hIfwGk.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-C-Zxn6n6.js → RestoreArchivedAreaModal-CQ3NdSvn.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-0uRu2G4V.js → Scene2DCanvas-BfcwE6aA.js} +1 -1
  15. package/dist/assets/{SceneManager-xyitf6BI.js → SceneManager-D409BuL6.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Bgcx1OI3.js → SkillsPanel-B0V-BFfO.js} +1 -1
  17. package/dist/assets/{SpawnModal-CS3BMuY9.js → SpawnModal-gPT83rW4.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-DNWrbv9p.js → SubordinateAssignmentModal-Dvvqv0yZ.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-D-pLDqne.js → TriggerManagerPanel-BmDBe8xx.js} +1 -1
  20. package/dist/assets/{WorkflowEditorPanel-Cu7zjFdE.js → WorkflowEditorPanel-PS4W8Q3F.js} +1 -1
  21. package/dist/assets/{index-cGfzMto2.js → index-BSdgxlrR.js} +1 -1
  22. package/dist/assets/{index-2BVW4z0_.js → index-BkpkUL3C.js} +1 -1
  23. package/dist/assets/{index-CZl-6UH7.js → index-C7cIg4BE.js} +1 -1
  24. package/dist/assets/{index-DEfCPZTr.js → index-CJYuOBJD.js} +3 -3
  25. package/dist/assets/{index-Yy2LrlI7.js → index-D9NmRir8.js} +2 -2
  26. package/dist/assets/{index-Dd0cfrn9.js → index-DvbZsLxj.js} +1 -1
  27. package/dist/assets/index-baPDjRvq.js +1 -0
  28. package/dist/assets/{index-DM70jqOd.js → index-cCrsvvfk.js} +1 -1
  29. package/dist/assets/main-B3L4mgQ4.css +1 -0
  30. package/dist/assets/{main-BNhrjJHj.js → main-BzSUj-VM.js} +5 -5
  31. package/dist/assets/{web-BnX_BsjB.js → web-DffxvD3t.js} +1 -1
  32. package/dist/assets/{web-CyBgxhK2.js → web-y3e1lmyW.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/dist/src/packages/server/data/builtin-skills/backup-restore.js +242 -0
  35. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  36. package/dist/src/packages/server/index.js +4 -0
  37. package/dist/src/packages/server/routes/agents.js +27 -0
  38. package/dist/src/packages/server/services/backup-service.js +148 -0
  39. package/package.json +2 -1
  40. package/scripts/backup-data.sh +116 -0
  41. package/scripts/krunner/install-krunner-integration.sh +53 -0
  42. package/scripts/krunner/org.riven.tide.krunner.service +3 -0
  43. package/scripts/krunner/plasma-runner-tide-commander.desktop +16 -0
  44. package/scripts/krunner/tide-krunner-runner.js +335 -0
  45. package/scripts/recover-agents.ts +623 -0
  46. package/dist/assets/index-CWyxpmuL.js +0 -1
  47. package/dist/assets/main-4iQEaD98.css +0 -1
@@ -1 +1 @@
1
- import{bI as a}from"./main-BNhrjJHj.js";import{ImpactStyle as i,NotificationType as r}from"./index-Yy2LrlI7.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
+ import{bI as a}from"./main-BzSUj-VM.js";import{ImpactStyle as i,NotificationType as r}from"./index-D9NmRir8.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{bI as s}from"./main-BNhrjJHj.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};
1
+ import{bI as s}from"./main-BzSUj-VM.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-BNhrjJHj.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-BzSUj-VM.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-4iQEaD98.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-B3L4mgQ4.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -0,0 +1,242 @@
1
+ export const backupRestore = {
2
+ slug: 'backup-restore',
3
+ name: 'Backup & Restore',
4
+ description: 'Use this skill to list, inspect, or restore Tide Commander data backups. Covers reviewing available backup snapshots, comparing them, and restoring specific config files or the full data directory from a backup.',
5
+ allowedTools: ['Bash(ls:*)', 'Bash(tar:*)', 'Bash(cp:*)', 'Bash(mv:*)', 'Bash(mkdir:*)', 'Bash(sqlite3:*)', 'Bash(sha256sum:*)', 'Bash(diff:*)', 'Bash(date:*)', 'Bash(stat:*)', 'Bash(bash:*)', 'Bash(curl:*)', 'Read', 'Grep', 'Glob'],
6
+ content: `# Backup & Restore
7
+
8
+ Tide Commander automatically backs up its data directory every hour using an in-process scheduler. This skill documents how backups work, where they live, and how to review or restore them.
9
+
10
+ ## How Backups Work
11
+
12
+ - The scheduler runs inside the Commander node process (no cron or external deps).
13
+ - It calls \`scripts/backup-data.sh\` which stages all top-level JSON configs and safely copies the SQLite database using \`sqlite3 .backup\`.
14
+ - A **content signature** (sha256, 16 chars) is computed from the staged files. If a backup with the same signature already exists, the run is skipped (no duplicate backups for unchanged data).
15
+ - Backups are compressed tarballs named \`backup-<YYYYMMDDThhmmss>-<signature>.tar.gz\`.
16
+
17
+ ## Rotation Policy
18
+
19
+ - The **8 newest** hourly backups are always kept.
20
+ - Plus the **most recent backup from each of the 2 most recent prior days** that have backups.
21
+ - Up to ~10 backups total.
22
+
23
+ ## Key Paths
24
+
25
+ | Item | Path |
26
+ |------|------|
27
+ | Live data directory | \`~/.local/share/tide-commander/\` |
28
+ | Backup directory | \`~/.local/share/tide-commander-backups/\` |
29
+ | Backup script | \`scripts/backup-data.sh\` (relative to project root) |
30
+ | Backup log | \`~/.local/share/tide-commander-backups/backup.log\` |
31
+ | Scheduler settings | \`~/.local/share/tide-commander/backup-settings.json\` |
32
+
33
+ ## What Is Backed Up
34
+
35
+ All top-level files in the data directory:
36
+
37
+ | File | Contents |
38
+ |------|----------|
39
+ | \`agents.json\` | Agent configurations, positions, session IDs |
40
+ | \`skills.json\` | Skill registry and assignments |
41
+ | \`triggers.json\` | Workflow trigger definitions |
42
+ | \`workflow-definitions.json\` | Workflow state machines |
43
+ | \`buildings.json\` | Database building configs |
44
+ | \`secrets.json\` | Encrypted API keys (AES-256-GCM) |
45
+ | \`custom-agent-classes.json\` | Custom agent class definitions |
46
+ | \`delegation-history.json\` | Boss agent delegation history |
47
+ | \`areas.json\` | Area/zone layouts |
48
+ | \`slack-config.json\` | Slack integration config |
49
+ | \`calendar-config.json\` | Google Calendar config |
50
+ | \`drive-config.json\` | Google Drive config |
51
+ | \`system-prompt.json\` | Global system prompt |
52
+ | \`session-history.json\` | Session history metadata |
53
+ | \`running-processes.json\` | Active process records |
54
+ | \`events.db\` | SQLite event store (trigger events, audit log, workflow instances, etc.) |
55
+ | \`*.json.bak\` | Atomic-write backup copies of each JSON file |
56
+
57
+ Subdirectories like \`query-history/\`, \`generated/\`, \`snapshots/\`, and \`templates/\` are **not** included (considered derivable).
58
+
59
+ ## Common Tasks
60
+
61
+ ### Check Backup Scheduler Status
62
+
63
+ \`\`\`bash
64
+ curl -s -H "X-Auth-Token: YOUR_TOKEN" http://localhost:5174/api/agents/system-settings/backup | python3 -m json.tool
65
+ \`\`\`
66
+
67
+ Returns: \`enabled\`, \`running\`, \`scriptPath\`, \`scriptExists\`, \`backupDir\`, \`lastRunAt\`, \`lastRunOk\`, \`lastRunError\`.
68
+
69
+ ### Enable / Disable Backups
70
+
71
+ \`\`\`bash
72
+ # Enable
73
+ curl -s -X POST -H "Content-Type: application/json" -H "X-Auth-Token: YOUR_TOKEN" http://localhost:5174/api/agents/system-settings/backup -d '{"enabled":true}'
74
+
75
+ # Disable
76
+ curl -s -X POST -H "Content-Type: application/json" -H "X-Auth-Token: YOUR_TOKEN" http://localhost:5174/api/agents/system-settings/backup -d '{"enabled":false}'
77
+ \`\`\`
78
+
79
+ ### Run a Manual Backup Now
80
+
81
+ \`\`\`bash
82
+ bash scripts/backup-data.sh
83
+ \`\`\`
84
+
85
+ Or from the project root if running a compiled install, the script is at the same relative path.
86
+
87
+ ### List Available Backups
88
+
89
+ \`\`\`bash
90
+ ls -lhtr ~/.local/share/tide-commander-backups/backup-*.tar.gz
91
+ \`\`\`
92
+
93
+ Filenames sort chronologically. The signature suffix lets you see which backups have identical content.
94
+
95
+ ### Inspect a Backup (List Contents)
96
+
97
+ \`\`\`bash
98
+ tar -tzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz
99
+ \`\`\`
100
+
101
+ ### Extract a Backup to a Temp Directory for Review
102
+
103
+ \`\`\`bash
104
+ REVIEW_DIR=$(mktemp -d)
105
+ tar -xzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz -C "$REVIEW_DIR"
106
+ ls -la "$REVIEW_DIR"
107
+ \`\`\`
108
+
109
+ Then inspect individual files:
110
+ \`\`\`bash
111
+ # View agents config from the backup
112
+ cat "$REVIEW_DIR/agents.json" | python3 -m json.tool | head -60
113
+
114
+ # Compare a file between backup and live
115
+ diff <(python3 -m json.tool "$REVIEW_DIR/agents.json") <(python3 -m json.tool ~/.local/share/tide-commander/agents.json)
116
+ \`\`\`
117
+
118
+ ### Compare Two Backups
119
+
120
+ \`\`\`bash
121
+ DIR_A=$(mktemp -d)
122
+ DIR_B=$(mktemp -d)
123
+ tar -xzf ~/.local/share/tide-commander-backups/backup-OLDER.tar.gz -C "$DIR_A"
124
+ tar -xzf ~/.local/share/tide-commander-backups/backup-NEWER.tar.gz -C "$DIR_B"
125
+ diff -rq "$DIR_A" "$DIR_B"
126
+ \`\`\`
127
+
128
+ ### Query the SQLite Database from a Backup
129
+
130
+ \`\`\`bash
131
+ REVIEW_DIR=$(mktemp -d)
132
+ tar -xzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz -C "$REVIEW_DIR"
133
+ sqlite3 "$REVIEW_DIR/events.db" ".tables"
134
+ sqlite3 "$REVIEW_DIR/events.db" "SELECT COUNT(*) FROM workflow_instances;"
135
+ \`\`\`
136
+
137
+ ## Restore Procedures
138
+
139
+ ### IMPORTANT: Safety Rules
140
+
141
+ 1. **Always stop Commander before restoring** to avoid write conflicts.
142
+ 2. **Back up the current live state first** before overwriting anything.
143
+ 3. **Prefer selective restore** (single files) over full restore when possible.
144
+ 4. **Never restore running-processes.json** — it tracks ephemeral PIDs that won't be valid after restart.
145
+
146
+ ### Restore a Single JSON Config File
147
+
148
+ This is the safest approach. Use when one specific config got corrupted or lost.
149
+
150
+ \`\`\`bash
151
+ # 1. Pick the backup to restore from
152
+ ls -lhtr ~/.local/share/tide-commander-backups/backup-*.tar.gz
153
+
154
+ # 2. Extract just the file you need
155
+ tar -xzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz -C /tmp ./agents.json
156
+
157
+ # 3. Back up the current live file
158
+ cp ~/.local/share/tide-commander/agents.json ~/.local/share/tide-commander/agents.json.pre-restore
159
+
160
+ # 4. Copy the restored file into place
161
+ cp /tmp/agents.json ~/.local/share/tide-commander/agents.json
162
+
163
+ # 5. Restart Commander to pick up the change
164
+ \`\`\`
165
+
166
+ Replace \`agents.json\` with whichever file you need to restore.
167
+
168
+ ### Restore the Full Data Directory
169
+
170
+ Use when the entire data directory is corrupted or missing.
171
+
172
+ \`\`\`bash
173
+ # 1. Stop Commander first
174
+
175
+ # 2. Back up whatever is currently there (even if broken)
176
+ mv ~/.local/share/tide-commander ~/.local/share/tide-commander-broken-$(date +%Y%m%d)
177
+
178
+ # 3. Create fresh data directory
179
+ mkdir -p ~/.local/share/tide-commander
180
+
181
+ # 4. Pick the backup to restore from
182
+ ls -lhtr ~/.local/share/tide-commander-backups/backup-*.tar.gz
183
+
184
+ # 5. Extract the chosen backup into the data directory
185
+ tar -xzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz -C ~/.local/share/tide-commander
186
+
187
+ # 6. Remove running-processes.json (stale PIDs)
188
+ rm -f ~/.local/share/tide-commander/running-processes.json
189
+ rm -f ~/.local/share/tide-commander/running-processes.json.bak
190
+
191
+ # 7. Remove .bak files (they'll be recreated on first write)
192
+ rm -f ~/.local/share/tide-commander/*.json.bak
193
+
194
+ # 8. Start Commander
195
+ \`\`\`
196
+
197
+ ### Restore Only the SQLite Event Database
198
+
199
+ \`\`\`bash
200
+ # 1. Extract the database from the backup
201
+ tar -xzf ~/.local/share/tide-commander-backups/backup-<TIMESTAMP>-<SIG>.tar.gz -C /tmp ./events.db
202
+
203
+ # 2. Back up the current one
204
+ cp ~/.local/share/tide-commander/events.db ~/.local/share/tide-commander/events.db.pre-restore
205
+
206
+ # 3. Replace it (Commander should be stopped or at minimum idle)
207
+ cp /tmp/events.db ~/.local/share/tide-commander/events.db
208
+
209
+ # 4. Remove WAL files so SQLite starts fresh
210
+ rm -f ~/.local/share/tide-commander/events.db-wal
211
+ rm -f ~/.local/share/tide-commander/events.db-shm
212
+ \`\`\`
213
+
214
+ ## Troubleshooting
215
+
216
+ ### Backups Not Being Created
217
+
218
+ 1. Check scheduler status via the API (see above).
219
+ 2. Check if the script exists: \`ls -la scripts/backup-data.sh\`
220
+ 3. Check the backup log: \`tail -20 ~/.local/share/tide-commander-backups/backup.log\`
221
+ 4. Run the script manually to see errors: \`bash scripts/backup-data.sh\`
222
+ 5. Ensure \`sqlite3\` is installed: \`which sqlite3\`
223
+
224
+ ### All Backups Have the Same Signature
225
+
226
+ This means data hasn't changed between runs. This is expected behavior — the dedup is working correctly. Backups are only created when content actually changes.
227
+
228
+ ### Backup Directory Growing Too Large
229
+
230
+ Check total size:
231
+ \`\`\`bash
232
+ du -sh ~/.local/share/tide-commander-backups/
233
+ \`\`\`
234
+
235
+ The rotation policy (8 hourly + 2 prior days) should keep it bounded. If backups are unusually large, the SQLite database may have grown. Consider running \`VACUUM\` on the live database:
236
+ \`\`\`bash
237
+ sqlite3 ~/.local/share/tide-commander/events.db "VACUUM;"
238
+ \`\`\`
239
+
240
+ Then the next backup will pick up the smaller database.
241
+ `,
242
+ };
@@ -21,6 +21,7 @@ import { bossInstructions } from './boss-instructions.js';
21
21
  import { workflowDesigner } from './workflow-designer.js';
22
22
  import { triggerDesigner } from './trigger-designer.js';
23
23
  import { workflowBuilder } from './workflow-builder.js';
24
+ import { backupRestore } from './backup-restore.js';
24
25
  /**
25
26
  * All built-in skills that ship with Tide Commander
26
27
  */
@@ -41,6 +42,7 @@ export const BUILTIN_SKILLS = [
41
42
  workflowDesigner,
42
43
  triggerDesigner,
43
44
  workflowBuilder,
45
+ backupRestore,
44
46
  ];
45
47
  /**
46
48
  * Get the ID for a built-in skill based on its slug
@@ -15,6 +15,7 @@ import * as eventQueries from './data/event-queries.js';
15
15
  import { logger, closeFileLogging, getLogFilePath, createLogger } from './utils/logger.js';
16
16
  import { setupTerminalWsProxy } from './services/terminal-proxy.js';
17
17
  import { initIntegrations, shutdownIntegrations, getIntegrationTriggerHandlers } from './integrations/integration-registry.js';
18
+ import { initBackupService, shutdownBackupService } from './services/backup-service.js';
18
19
  // Configuration
19
20
  const PORT = process.env.PORT || 6200;
20
21
  const HOST = process.env.HOST || (process.env.LISTEN_ALL_INTERFACES ? '::' : '127.0.0.1');
@@ -138,6 +139,8 @@ async function main() {
138
139
  for (const handler of getIntegrationTriggerHandlers()) {
139
140
  triggerService.registerHandler(handler);
140
141
  }
142
+ // Start hourly backup scheduler (reads persisted enabled/disabled setting)
143
+ initBackupService();
141
144
  logger.server.log(`Data directory: ${getDataDir()}`);
142
145
  logger.server.log(`Log file: ${getLogFilePath()}`);
143
146
  // Create Express app and HTTP server
@@ -199,6 +202,7 @@ async function main() {
199
202
  }, FORCE_SHUTDOWN_TIMEOUT_MS);
200
203
  forceShutdownTimer.unref();
201
204
  try {
205
+ shutdownBackupService();
202
206
  triggerService.shutdown();
203
207
  workflowService.shutdown();
204
208
  await shutdownIntegrations();
@@ -17,6 +17,7 @@ import { buildCustomAgentConfig } from '../websocket/handlers/command-handler.js
17
17
  import { clearDelegation, getBossForSubordinate } from '../websocket/handlers/boss-response-handler.js';
18
18
  import { OpencodeBackend } from '../opencode/backend.js';
19
19
  import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnabled, setEchoPromptEnabled, getCodexBinaryPath, setCodexBinaryPath, isTmuxModeEnabled, setTmuxModeEnabled } from '../services/system-prompt-service.js';
20
+ import { getBackupStatus, setBackupEnabled } from '../services/backup-service.js';
20
21
  const log = createLogger('Routes');
21
22
  const router = Router();
22
23
  // Store for broadcasting via WebSocket
@@ -1050,4 +1051,30 @@ router.post('/system-settings/tmux-mode', (req, res) => {
1050
1051
  res.status(500).json({ error: err.message });
1051
1052
  }
1052
1053
  });
1054
+ // GET /api/system-settings/backup - Get hourly backup scheduler status
1055
+ router.get('/system-settings/backup', (_req, res) => {
1056
+ try {
1057
+ res.json(getBackupStatus());
1058
+ }
1059
+ catch (err) {
1060
+ log.error(' Failed to get backup status:', err);
1061
+ res.status(500).json({ error: err.message });
1062
+ }
1063
+ });
1064
+ // POST /api/system-settings/backup - Enable or disable the hourly backup scheduler
1065
+ router.post('/system-settings/backup', (req, res) => {
1066
+ try {
1067
+ const { enabled } = req.body;
1068
+ if (typeof enabled !== 'boolean') {
1069
+ res.status(400).json({ error: 'enabled must be a boolean' });
1070
+ return;
1071
+ }
1072
+ const status = setBackupEnabled(enabled);
1073
+ res.json({ success: true, ...status });
1074
+ }
1075
+ catch (err) {
1076
+ log.error(' Failed to set backup enabled:', err);
1077
+ res.status(500).json({ error: err.message });
1078
+ }
1079
+ });
1053
1080
  export default router;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Backup Service
3
+ *
4
+ * In-process hourly backup scheduler. Runs inside the Commander node process
5
+ * (no cron, no external deps). When Commander is down the data files can't
6
+ * change, so there's no downside to coupling the backup lifecycle to the
7
+ * server process.
8
+ *
9
+ * The actual backup work is done by scripts/backup-data.sh (shipped with the
10
+ * package). This service calls it via child_process.execFile on a setInterval.
11
+ */
12
+ import { execFile } from 'child_process';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import * as os from 'os';
16
+ import { fileURLToPath } from 'url';
17
+ import { createLogger } from '../utils/logger.js';
18
+ const log = createLogger('BackupService');
19
+ const INTERVAL_MS = 60 * 60 * 1000; // 1 hour
20
+ // ── Paths ────────────────────────────────────────────────────────────────────
21
+ const DATA_DIR = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'tide-commander');
22
+ const SETTINGS_FILE = path.join(DATA_DIR, 'backup-settings.json');
23
+ const BACKUP_DIR = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'tide-commander-backups');
24
+ function findProjectRoot() {
25
+ let dir = path.dirname(fileURLToPath(import.meta.url));
26
+ while (dir !== '/' && dir !== '') {
27
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
28
+ return dir;
29
+ }
30
+ const parent = path.dirname(dir);
31
+ if (parent === dir)
32
+ break;
33
+ dir = parent;
34
+ }
35
+ return process.cwd();
36
+ }
37
+ const PROJECT_ROOT = findProjectRoot();
38
+ export function getBackupScriptPath() {
39
+ return path.join(PROJECT_ROOT, 'scripts', 'backup-data.sh');
40
+ }
41
+ function readSettings() {
42
+ try {
43
+ if (fs.existsSync(SETTINGS_FILE)) {
44
+ const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'));
45
+ return { enabled: !!data.enabled };
46
+ }
47
+ }
48
+ catch (err) {
49
+ log.error(`Failed to read backup settings: ${err.message}`);
50
+ }
51
+ // Default: enabled — backups are on for everyone out of the box.
52
+ return { enabled: true };
53
+ }
54
+ function writeSettings(settings) {
55
+ if (!fs.existsSync(DATA_DIR)) {
56
+ fs.mkdirSync(DATA_DIR, { recursive: true });
57
+ }
58
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf-8');
59
+ }
60
+ // ── In-process scheduler ─────────────────────────────────────────────────────
61
+ let timer = null;
62
+ let lastRunAt = null;
63
+ let lastRunOk = null;
64
+ let lastRunError = null;
65
+ function runBackupScript() {
66
+ const scriptPath = getBackupScriptPath();
67
+ if (!fs.existsSync(scriptPath)) {
68
+ log.error(`Backup script not found at ${scriptPath} — skipping`);
69
+ lastRunAt = new Date().toISOString();
70
+ lastRunOk = false;
71
+ lastRunError = 'Script not found';
72
+ return;
73
+ }
74
+ log.log('Starting hourly backup…');
75
+ execFile('bash', [scriptPath], { timeout: 120_000 }, (err, stdout, stderr) => {
76
+ lastRunAt = new Date().toISOString();
77
+ if (err) {
78
+ lastRunOk = false;
79
+ lastRunError = stderr?.trim() || err.message;
80
+ log.error(`Backup failed: ${lastRunError}`);
81
+ }
82
+ else {
83
+ lastRunOk = true;
84
+ lastRunError = null;
85
+ const msg = stdout?.trim();
86
+ if (msg)
87
+ log.log(msg);
88
+ }
89
+ });
90
+ }
91
+ function startScheduler() {
92
+ if (timer)
93
+ return; // already running
94
+ timer = setInterval(runBackupScript, INTERVAL_MS);
95
+ timer.unref(); // don't hold the process open
96
+ log.log('Backup scheduler started (every 1 h)');
97
+ // Run immediately on first start so the first backup doesn't wait an hour.
98
+ runBackupScript();
99
+ }
100
+ function stopScheduler() {
101
+ if (!timer)
102
+ return;
103
+ clearInterval(timer);
104
+ timer = null;
105
+ log.log('Backup scheduler stopped');
106
+ }
107
+ export function getBackupStatus() {
108
+ const settings = readSettings();
109
+ return {
110
+ enabled: settings.enabled,
111
+ running: timer !== null,
112
+ scriptPath: getBackupScriptPath(),
113
+ scriptExists: fs.existsSync(getBackupScriptPath()),
114
+ backupDir: BACKUP_DIR,
115
+ lastRunAt,
116
+ lastRunOk,
117
+ lastRunError,
118
+ };
119
+ }
120
+ export function setBackupEnabled(enabled) {
121
+ writeSettings({ enabled });
122
+ if (enabled) {
123
+ startScheduler();
124
+ }
125
+ else {
126
+ stopScheduler();
127
+ }
128
+ return getBackupStatus();
129
+ }
130
+ /**
131
+ * Called once at server boot. Reads persisted setting and starts the
132
+ * scheduler if backups are enabled.
133
+ */
134
+ export function initBackupService() {
135
+ const { enabled } = readSettings();
136
+ if (enabled) {
137
+ startScheduler();
138
+ }
139
+ else {
140
+ log.log('Backups disabled — scheduler not started');
141
+ }
142
+ }
143
+ /**
144
+ * Called on graceful shutdown to clear the interval timer cleanly.
145
+ */
146
+ export function shutdownBackupService() {
147
+ stopScheduler();
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.65.0",
3
+ "version": "1.66.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
+ "scripts",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ # Tide Commander data backup script.
3
+ #
4
+ # - Backs up ~/.local/share/tide-commander (SQLite + JSON configs).
5
+ # - Uses sqlite3 .backup to avoid corruption from concurrent writes.
6
+ # - Names backups with a content signature so unchanged state does not
7
+ # create a new copy.
8
+ # - Rotation: keeps the 8 newest hourly backups, plus the most recent
9
+ # backup from each of the 2 most recent prior days.
10
+
11
+ set -euo pipefail
12
+
13
+ DATA_DIR="${TIDE_COMMANDER_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/tide-commander}"
14
+ BACKUP_DIR="${TIDE_COMMANDER_BACKUP_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/tide-commander-backups}"
15
+ HOURLY_KEEP=8
16
+ PRIOR_DAYS_KEEP=2
17
+
18
+ log() { printf '[tide-backup %s] %s\n' "$(date -Iseconds)" "$*"; }
19
+
20
+ if [[ ! -d "$DATA_DIR" ]]; then
21
+ log "data dir not found: $DATA_DIR (nothing to back up)"
22
+ exit 0
23
+ fi
24
+
25
+ mkdir -p "$BACKUP_DIR"
26
+
27
+ STAGE_DIR="$(mktemp -d)"
28
+ trap 'rm -rf "$STAGE_DIR"' EXIT
29
+
30
+ # Stage JSON / text config files (top-level only; subdirs like query-history
31
+ # and generated are considered derivable and skipped to keep backups small).
32
+ shopt -s nullglob
33
+ for f in "$DATA_DIR"/*.json "$DATA_DIR"/*.json.bak; do
34
+ [[ -f "$f" ]] && cp -p "$f" "$STAGE_DIR/"
35
+ done
36
+ shopt -u nullglob
37
+
38
+ # Safe SQLite backup. Requires sqlite3 on PATH; if missing, fall back to a
39
+ # plain copy of the main db file.
40
+ if [[ -f "$DATA_DIR/events.db" ]]; then
41
+ if command -v sqlite3 >/dev/null 2>&1; then
42
+ sqlite3 "$DATA_DIR/events.db" ".backup '$STAGE_DIR/events.db'"
43
+ else
44
+ log "sqlite3 not found, using plain copy (may be inconsistent if written during copy)"
45
+ cp -p "$DATA_DIR/events.db" "$STAGE_DIR/events.db"
46
+ fi
47
+ fi
48
+
49
+ # Compute signature: deterministic hash of staged file contents + names.
50
+ # (Mtimes intentionally excluded so identical content skips new backup.)
51
+ SIGNATURE=$(
52
+ cd "$STAGE_DIR" && \
53
+ find . -type f -print0 | LC_ALL=C sort -z | \
54
+ xargs -0 sha256sum | sha256sum | cut -c1-16
55
+ )
56
+
57
+ # Skip if a backup with this signature already exists.
58
+ if compgen -G "$BACKUP_DIR/backup-*-$SIGNATURE.tar.gz" >/dev/null; then
59
+ log "no changes since last backup (sig=$SIGNATURE), skipping"
60
+ exit 0
61
+ fi
62
+
63
+ TS="$(date +%Y%m%dT%H%M%S)"
64
+ OUT="$BACKUP_DIR/backup-${TS}-${SIGNATURE}.tar.gz"
65
+ tar -C "$STAGE_DIR" -czf "$OUT" .
66
+ log "created $OUT"
67
+
68
+ # Rotation: files are named backup-YYYYMMDDThhmmss-<sig>.tar.gz, which sorts
69
+ # correctly lexicographically (newest last when sorted ascending).
70
+ rotate() {
71
+ local today
72
+ today="$(date +%Y%m%d)"
73
+
74
+ mapfile -t files < <(ls -1 "$BACKUP_DIR"/backup-*.tar.gz 2>/dev/null | sort -r) || return 0
75
+ [[ ${#files[@]} -eq 0 ]] && return 0
76
+
77
+ declare -A keep=()
78
+
79
+ # Keep newest $HOURLY_KEEP regardless of day.
80
+ local i=0
81
+ for f in "${files[@]}"; do
82
+ if (( i < HOURLY_KEEP )); then
83
+ keep["$f"]=1
84
+ i=$((i + 1))
85
+ else
86
+ break
87
+ fi
88
+ done
89
+
90
+ # Keep the newest backup from each of the most recent prior days.
91
+ declare -A seen_days=()
92
+ local prior=0
93
+ for f in "${files[@]}"; do
94
+ local name day
95
+ name="$(basename "$f")"
96
+ # Strip "backup-" (7 chars), take the next 8 (YYYYMMDD).
97
+ day="${name:7:8}"
98
+ [[ "$day" == "$today" ]] && continue
99
+ [[ -n "${seen_days[$day]:-}" ]] && continue
100
+ seen_days["$day"]=1
101
+ keep["$f"]=1
102
+ prior=$((prior + 1))
103
+ (( prior >= PRIOR_DAYS_KEEP )) && break
104
+ done
105
+
106
+ for f in "${files[@]}"; do
107
+ if [[ -z "${keep[$f]:-}" ]]; then
108
+ rm -f "$f"
109
+ log "rotated out $(basename "$f")"
110
+ fi
111
+ done
112
+ }
113
+
114
+ rotate
115
+
116
+ log "done (kept $(ls -1 "$BACKUP_DIR"/backup-*.tar.gz 2>/dev/null | wc -l) backup(s))"