tide-commander 1.110.2 → 1.110.4

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 (44) hide show
  1. package/dist/assets/{BossLogsModal-DBeMak5-.js → BossLogsModal-BBO4P4qG.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DDl6qgnq.js → BossSpawnModal-DJ1DmEWp.js} +1 -1
  3. package/dist/assets/{ControlsModal-BBUukX-p.js → ControlsModal-DeRColMt.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BWf3XDEI.js → DockerLogsModal-DGpatLiw.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-CIOdhnaW.js → EmbeddedEditor-Dsgf6hMF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-qG-_WeOb.js → GmailOAuthSetup-CGis0yjA.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-BK3lzU4n.js → GoogleOAuthSetup-Bnhf_nts.js} +1 -1
  8. package/dist/assets/{IframeModal-D_NEs_Ld.js → IframeModal-BahnrxAm.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-yphWX1ZD.js → IntegrationsPanel-BUs_7XBA.js} +2 -2
  10. package/dist/assets/{LogViewerModal-B0h9NNRk.js → LogViewerModal-DXxjS8NR.js} +1 -1
  11. package/dist/assets/{MonitoringModal-Ciw7KnXH.js → MonitoringModal-DPeyASIV.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C1dYQaff.js → PM2LogsModal-kNNjOmdB.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-DOwzfu-D.js → RestoreArchivedAreaModal-CVHj28LX.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-MHfv4Rzg.js → Scene2DCanvas-BShs3yx7.js} +1 -1
  15. package/dist/assets/{SceneManager-cm1Kj0YD.js → SceneManager-CF89Ktyf.js} +1 -1
  16. package/dist/assets/{SkillsPanel-DLwMkORc.js → SkillsPanel-BVRP74vV.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Dql_wPMX.js → SlackMultiInstanceSetup-f6boDwm1.js} +1 -1
  18. package/dist/assets/{SpawnModal-DzPTSOKV.js → SpawnModal-Bm8rg45q.js} +1 -1
  19. package/dist/assets/StatisticsModal-DuNj8UzS.js +1 -0
  20. package/dist/assets/{SubordinateAssignmentModal-CoLzD3z2.js → SubordinateAssignmentModal-Cn3e2lNE.js} +1 -1
  21. package/dist/assets/{TriggerManagerPanel-BOi2zr7N.js → TriggerManagerPanel-Deo2dE4T.js} +1 -1
  22. package/dist/assets/{WorkflowEditorPanel-DB1ygPDS.js → WorkflowEditorPanel-D-_duRlu.js} +1 -1
  23. package/dist/assets/{index-DKASWGtq.js → index-Bb-7Dvp1.js} +1 -1
  24. package/dist/assets/{index-C6EHHgC0.js → index-BsykE0PB.js} +1 -1
  25. package/dist/assets/{index-D4XW6MSF.js → index-C9A7qvBF.js} +2 -2
  26. package/dist/assets/{index-DKdZI-Ar.js → index-CZGINGY-.js} +1 -1
  27. package/dist/assets/{index-6Wak7CPm.js → index-DZu9t3BI.js} +1 -1
  28. package/dist/assets/{index-BZsQOteM.js → index-DeeuvLUM.js} +1 -1
  29. package/dist/assets/index-v1ImDk7h.js +11 -0
  30. package/dist/assets/{index-BXJSMT4a.js → index-y3mA_lBK.js} +3 -3
  31. package/dist/assets/{index-ThbMNdRp.js → index-zphXsHhS.js} +1 -1
  32. package/dist/assets/{main-BxA3Bhkv.js → main-CyZg1kKu.js} +94 -94
  33. package/dist/assets/{main-BLrGoeQ_.css → main-VciFPjA1.css} +1 -1
  34. package/dist/assets/{web-zdygXcYu.js → web-BKgFBlgl.js} +1 -1
  35. package/dist/assets/{web-BskC98UQ.js → web-BZcyP_IR.js} +1 -1
  36. package/dist/assets/{web-CSRmX8Ge.js → web-VEWFSkZI.js} +1 -1
  37. package/dist/index.html +2 -2
  38. package/dist/src/packages/server/data/builtin-skills/send-message-to-agent.js +54 -1
  39. package/dist/src/packages/server/routes/agents.js +43 -0
  40. package/dist/src/packages/server/services/collapse-context.js +72 -0
  41. package/dist/src/packages/server/services/runtime-service.js +46 -0
  42. package/dist/src/packages/server/websocket/handlers/agent-handler.js +27 -12
  43. package/package.json +1 -1
  44. package/dist/assets/index-BP6ukiYG.js +0 -11
@@ -1 +1 @@
1
- import{cl as s}from"./main-BxA3Bhkv.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{cm as s}from"./main-CyZg1kKu.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 +1 @@
1
- import{cl as a}from"./main-BxA3Bhkv.js";import{ImpactStyle as i,NotificationType as r}from"./index-D4XW6MSF.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{cm as a}from"./main-CyZg1kKu.js";import{ImpactStyle as i,NotificationType as r}from"./index-C9A7qvBF.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{cl as t}from"./main-BxA3Bhkv.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
+ import{cm as t}from"./main-CyZg1kKu.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};
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-BxA3Bhkv.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-CyZg1kKu.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-BLrGoeQ_.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-VciFPjA1.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -47,5 +47,58 @@ EOF
47
47
  - Always prefix messages with your identity so the receiver knows who sent it
48
48
  - Use heredoc syntax (\`-d @- <<'EOF'\`) to avoid JSON escaping problems
49
49
  - Keep messages simple - avoid special characters like backslashes or nested quotes
50
- - Works with all agent types including boss agents`,
50
+ - Works with all agent types including boss agents
51
+
52
+ ## Collapsing an Agent's Context (\`/compact\`)
53
+
54
+ \`POST /api/agents/AGENT_ID/message\` cannot deliver Claude Code slash commands —
55
+ the slash is passed through as message body instead of being intercepted by the
56
+ CLI. For that, use the dedicated endpoint:
57
+
58
+ \`\`\`bash
59
+ curl -s -X POST http://localhost:5174/api/agents/AGENT_ID/collapse-context
60
+ \`\`\`
61
+
62
+ This sends Claude Code's \`/compact\` slash command to the agent's runner, which
63
+ compresses its conversation history. Works for ANY agent including yourself
64
+ (auto-collapse from a cron or end-of-flow step).
65
+
66
+ Response shape:
67
+
68
+ \`\`\`json
69
+ { "success": true, "agentId": "AGENT_ID", "status": "collapse-initiated" }
70
+ \`\`\`
71
+
72
+ Status codes:
73
+ - \`200\` + \`status: "collapse-initiated"\` — \`/compact\` dispatched
74
+ - \`200\` + \`status: "queued"\` — agent was busy; \`/compact\` is queued and will fire automatically when the agent next goes idle (only when you pass \`waitForIdle: true\` in the body)
75
+ - \`404\` + \`status: "not-found"\` — agent id doesn't exist
76
+ - \`409\` + \`status: "busy"\` + \`currentStatus\` — agent isn't idle and \`waitForIdle\` was not set; retry once it goes idle
77
+ - \`500\` + \`status: "error"\` — runtime error sending the command
78
+
79
+ ### Auto-collapse from inside your OWN turn (\`waitForIdle\`)
80
+
81
+ When the agent calling this endpoint is the SAME agent being collapsed — e.g. a
82
+ cron-driven end-of-flow step that wraps up by collapsing its own context — the
83
+ target is by definition still \`working\` at the time of the request, so the
84
+ default behavior returns \`409 busy\`. Opt into queueing by passing
85
+ \`waitForIdle: true\`:
86
+
87
+ \`\`\`bash
88
+ curl -s -X POST http://localhost:5174/api/agents/YOUR_AGENT_ID/collapse-context \\
89
+ -H "Content-Type: application/json" \\
90
+ -d '{"waitForIdle": true}'
91
+ \`\`\`
92
+
93
+ Response is \`200 + {"status": "queued"}\` — the \`/compact\` is held in an
94
+ in-process queue and dispatched the first time the agent's status transitions
95
+ to \`idle\`. Coalesced: stacking multiple \`waitForIdle\` calls for the same
96
+ agent results in a single drained \`/compact\`, not N.
97
+
98
+ Use this for the final step of any agent's autonomous flow when you want the
99
+ next turn to start with a compressed context.
100
+
101
+ **When to use which endpoint:**
102
+ - \`/message\` — deliver content / instructions to an agent (what you usually want)
103
+ - \`/collapse-context\` — trigger the \`/compact\` slash command (the only path that works for slash commands; \`/message\` will not)`,
51
104
  };
@@ -1065,6 +1065,49 @@ router.post('/:id/message', async (req, res) => {
1065
1065
  res.status(500).json({ error: err.message });
1066
1066
  }
1067
1067
  });
1068
+ // POST /api/agents/:id/collapse-context — Send Claude Code's /compact slash command
1069
+ // to the agent's runner. Use case: any agent (including the same agent via a
1070
+ // scheduled cron) auto-collapsing its own context. Plain /message can't carry
1071
+ // slash commands; this is the only correct path.
1072
+ //
1073
+ // Body (optional):
1074
+ // { "waitForIdle": true } — if the agent is currently busy, queue the
1075
+ // /compact and fire it automatically the first time the agent goes idle.
1076
+ // The main use case is the SAME agent calling this from inside its own
1077
+ // turn (e.g. end-of-flow cron step) — by definition still `working` when
1078
+ // the request lands. Default false keeps the existing 409-busy behavior.
1079
+ router.post('/:id/collapse-context', async (req, res) => {
1080
+ try {
1081
+ const agentId = req.params.id;
1082
+ const body = (req.body ?? {});
1083
+ const waitForIdle = body.waitForIdle === true;
1084
+ const result = await runtimeService.collapseAgentContext(agentId, { waitForIdle });
1085
+ switch (result.status) {
1086
+ case 'collapse-initiated':
1087
+ log.log(`API collapse-context dispatched for agent ${agentId}`);
1088
+ res.status(200).json({ success: true, agentId, status: 'collapse-initiated' });
1089
+ return;
1090
+ case 'queued':
1091
+ log.log(`API collapse-context queued for agent ${agentId} (waiting for idle)`);
1092
+ res.status(200).json({ success: true, agentId, status: 'queued' });
1093
+ return;
1094
+ case 'not-found':
1095
+ res.status(404).json({ success: false, agentId, status: 'not-found', error: `Agent not found: ${agentId}` });
1096
+ return;
1097
+ case 'busy':
1098
+ res.status(409).json({ success: false, agentId, status: 'busy', currentStatus: result.currentStatus, error: 'Cannot collapse context while agent is busy' });
1099
+ return;
1100
+ case 'error':
1101
+ log.error(` Failed to collapse context for ${agentId}: ${result.error}`);
1102
+ res.status(500).json({ success: false, agentId, status: 'error', error: result.error });
1103
+ return;
1104
+ }
1105
+ }
1106
+ catch (err) {
1107
+ log.error(' Failed to collapse context (route):', err);
1108
+ res.status(500).json({ error: err?.message || 'Failed to collapse context' });
1109
+ }
1110
+ });
1068
1111
  // POST /api/agents/:id/report-task - Subordinate reports task completion to its boss
1069
1112
  router.post('/:id/report-task', async (req, res) => {
1070
1113
  try {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Collapse-Context Service
3
+ *
4
+ * Owns the small queue of pending `/compact` requests that need to fire as
5
+ * soon as their agent transitions back to idle. Two callers use it: the REST
6
+ * endpoint `POST /api/agents/:id/collapse-context` and the WS
7
+ * `collapse_context` handler.
8
+ *
9
+ * Why a separate module: the queue + idle-drain logic is the only piece of
10
+ * state we want to unit-test in isolation, and `runtime-service` is heavy with
11
+ * unrelated deps. We expose a factory so tests can wire fake `getAgent` /
12
+ * `sendCommand` / `subscribe` deps and exercise every branch without spinning
13
+ * up the real agent service.
14
+ */
15
+ export function createCollapseContextService(deps) {
16
+ const pending = new Set();
17
+ let unsubscribe = null;
18
+ // Lazy subscription — we only attach the listener once a real queue exists.
19
+ // Stays attached for the process lifetime; the pending set is the gate that
20
+ // decides whether the listener does anything on each event.
21
+ function ensureSubscribed() {
22
+ if (unsubscribe)
23
+ return;
24
+ unsubscribe = deps.subscribe((event, data) => {
25
+ if (event !== 'updated' || !data || typeof data === 'string')
26
+ return;
27
+ const agent = data;
28
+ if (agent.status !== 'idle')
29
+ return;
30
+ if (!pending.has(agent.id))
31
+ return;
32
+ pending.delete(agent.id);
33
+ void drain(agent.id);
34
+ });
35
+ }
36
+ async function drain(agentId) {
37
+ try {
38
+ await deps.sendCommand(agentId, '/compact');
39
+ deps.log?.info?.(`Drained queued collapse for ${agentId} after idle transition`);
40
+ }
41
+ catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ deps.log?.error?.(`Failed to drain queued collapse for ${agentId}: ${msg}`);
44
+ }
45
+ }
46
+ async function collapse(agentId, opts = {}) {
47
+ const agent = deps.getAgent(agentId);
48
+ if (!agent)
49
+ return { status: 'not-found' };
50
+ if (agent.status === 'idle') {
51
+ try {
52
+ await deps.sendCommand(agentId, '/compact');
53
+ return { status: 'collapse-initiated' };
54
+ }
55
+ catch (err) {
56
+ return { status: 'error', error: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ }
59
+ if (opts.waitForIdle) {
60
+ ensureSubscribed();
61
+ // Set.add is idempotent — multiple `waitForIdle` calls coalesce into one drain.
62
+ pending.add(agentId);
63
+ return { status: 'queued' };
64
+ }
65
+ return { status: 'busy', currentStatus: agent.status };
66
+ }
67
+ return {
68
+ collapse,
69
+ hasPending: (id) => pending.has(id),
70
+ pendingCount: () => pending.size,
71
+ };
72
+ }
@@ -187,6 +187,52 @@ export async function sendCommand(agentId, command, systemPrompt, forceNewSessio
187
187
  export async function sendSilentCommand(agentId, command) {
188
188
  await commandExecution.sendSilentCommand(agentId, command);
189
189
  }
190
+ /**
191
+ * Send Claude Code's `/compact` slash command to collapse an agent's context.
192
+ * Only fires when the agent exists AND is idle — the CLI rejects slash commands
193
+ * mid-turn. Pass `waitForIdle: true` to queue the `/compact` for when the agent
194
+ * next goes idle (the main use case is an agent auto-collapsing its OWN context
195
+ * at the end of a turn — by definition still `working` when it makes the call).
196
+ *
197
+ * Callers: WS `collapse_context` handler, REST `POST /api/agents/:id/collapse-context`.
198
+ * Plain `/message` cannot carry slash commands (they're sent as message body, not
199
+ * intercepted by the CLI) — this helper is the only correct path.
200
+ *
201
+ * Backed by a small in-module queue + a one-shot agent-event subscriber; see
202
+ * `./collapse-context.ts` for the unit-testable factory.
203
+ */
204
+ import { createCollapseContextService } from './collapse-context.js';
205
+ let collapseService = null;
206
+ function getCollapseService() {
207
+ if (collapseService)
208
+ return collapseService;
209
+ collapseService = createCollapseContextService({
210
+ getAgent: (id) => {
211
+ const a = agentService.getAgent(id);
212
+ return a ? { id: a.id, status: a.status } : undefined;
213
+ },
214
+ sendCommand: (id, cmd) => commandExecution.sendCommand(id, cmd),
215
+ subscribe: (listener) => agentService.subscribe((event, data) => {
216
+ // agentService emits agent objects on 'updated' — narrow to the
217
+ // { id, status } slice the collapse-context module cares about.
218
+ if (event === 'updated' && data && typeof data !== 'string') {
219
+ listener(event, { id: data.id, status: data.status });
220
+ }
221
+ else {
222
+ listener(event, typeof data === 'string' ? data : undefined);
223
+ }
224
+ }),
225
+ log: {
226
+ info: (m) => log.log(m),
227
+ warn: (m) => log.warn(m),
228
+ error: (m) => log.error(m),
229
+ },
230
+ });
231
+ return collapseService;
232
+ }
233
+ export async function collapseAgentContext(agentId, opts) {
234
+ return getCollapseService().collapse(agentId, opts);
235
+ }
190
236
  export async function stopAgent(agentId) {
191
237
  await commandExecution.stopAgent(agentId);
192
238
  }
@@ -227,22 +227,37 @@ export function handleRequestSessionHistory(ctx, payload) {
227
227
  });
228
228
  }
229
229
  /**
230
- * Handle collapse_context message - sends /compact command to collapse context
230
+ * Handle collapse_context message - sends /compact command to collapse context.
231
+ * Thin wrapper around runtimeService.collapseAgentContext that maps each
232
+ * discriminated result back to the WS activity stream.
231
233
  */
232
234
  export async function handleCollapseContext(ctx, payload) {
233
- const agent = agentService.getAgent(payload.agentId);
234
- if (agent && agent.status === 'idle') {
235
- try {
236
- await runtimeService.sendCommand(payload.agentId, '/compact');
235
+ // WS path never opts into waitForIdle — the UI button only makes sense when
236
+ // the agent is already idle. Default behavior (409-equivalent activity msg
237
+ // for busy) is preserved.
238
+ const result = await runtimeService.collapseAgentContext(payload.agentId);
239
+ switch (result.status) {
240
+ case 'collapse-initiated':
237
241
  ctx.sendActivity(payload.agentId, 'Context collapse initiated');
238
- }
239
- catch (err) {
240
- log.error(` Failed to collapse context: ${err}`);
242
+ return;
243
+ case 'queued':
244
+ // Not reachable from this path (we don't pass waitForIdle), but the
245
+ // exhaustive switch keeps the type system honest if a future caller
246
+ // flips the flag here.
247
+ ctx.sendActivity(payload.agentId, 'Context collapse queued — will fire when agent is idle');
248
+ return;
249
+ case 'busy':
250
+ ctx.sendActivity(payload.agentId, 'Cannot collapse context while agent is busy');
251
+ return;
252
+ case 'not-found':
253
+ // No agent → no activity stream to write to; quietly drop (the UI shouldn't
254
+ // be able to reach this state, and logging keeps the breadcrumb).
255
+ log.warn(`collapse_context for unknown agent ${payload.agentId}`);
256
+ return;
257
+ case 'error':
258
+ log.error(` Failed to collapse context: ${result.error}`);
241
259
  ctx.sendActivity(payload.agentId, 'Failed to collapse context');
242
- }
243
- }
244
- else {
245
- ctx.sendActivity(payload.agentId, 'Cannot collapse context while agent is busy');
260
+ return;
246
261
  }
247
262
  }
248
263
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.110.2",
3
+ "version": "1.110.4",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",