tide-commander 1.154.0 → 1.155.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 (51) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/assets/{BossLogsModal-Bo0pUCWU.js → BossLogsModal-OFobifVq.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BOOHaqaD.js → BossSpawnModal-Dho-x6rm.js} +1 -1
  4. package/dist/assets/{ControlsModal-DItGV-CW.js → ControlsModal-DJBDhvNO.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-DjAZ3GL7.js → DockerLogsModal-C9TRTcsO.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-Cihkr8Xb.js → EmbeddedEditor-GDE_Khwz.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-G1XqiM44.js → GmailOAuthSetup-D9m5zMWC.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-Ce6gojdm.js → GoogleOAuthSetup-BxkN7LjY.js} +1 -1
  9. package/dist/assets/{IframeModal-CSSZJzPv.js → IframeModal-Do_I_wI7.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-EspPH09n.js → IntegrationsPanel-jRrwC2-G.js} +2 -2
  11. package/dist/assets/{LogViewerModal-Bn-HDysM.js → LogViewerModal-BtcUe5i2.js} +1 -1
  12. package/dist/assets/{MonitoringModal-DuSLTKVR.js → MonitoringModal-BTmSXrzv.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-Bo2OtaQd.js → PM2LogsModal-CUf7NGHz.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-BOx-V6tl.js → RestoreArchivedAreaModal-B72UKeRP.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-BIUbi14G.js → Scene2DCanvas-BCfxX7JU.js} +1 -1
  16. package/dist/assets/{SceneManager-CIWrGc-K.js → SceneManager-BQubdlgd.js} +1 -1
  17. package/dist/assets/{SkillsPanel-_KHtTDhC.js → SkillsPanel-3qreMiJs.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-DbQ3rUtd.js → SlackMultiInstanceSetup-BSCT5nAB.js} +1 -1
  19. package/dist/assets/{SpawnModal-CYEoOMPW.js → SpawnModal-DaWjnugM.js} +1 -1
  20. package/dist/assets/{StatisticsModal-BJ-coeis.js → StatisticsModal-DCjCAU2o.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-5Z3jvbM9.js → SubordinateAssignmentModal-9iPXraIi.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-DvC0xP9r.js → TriggerManagerPanel-B4dCLEi_.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-6ym1_csr.js → WorkflowEditorPanel-BaYXKfYH.js} +1 -1
  24. package/dist/assets/{area-logos-mWPCtCnf.js → area-logos-CgVT13bp.js} +1 -1
  25. package/dist/assets/{index-8VVbTmtb.js → index-0y0WY2XK.js} +1 -1
  26. package/dist/assets/{index-DxyZ-rYT.js → index-C-tbJxGb.js} +1 -1
  27. package/dist/assets/{index-CS8OutmQ.js → index-C3Yb-3TP.js} +1 -1
  28. package/dist/assets/index-C3jAbuv2.js +1 -0
  29. package/dist/assets/{index-CnESn83U.js → index-C7t_6aP3.js} +2 -2
  30. package/dist/assets/{index-DZiNmiyC.js → index-CetQ5OX1.js} +1 -1
  31. package/dist/assets/{index-CT2vtbed.js → index-D8dlRudN.js} +3 -3
  32. package/dist/assets/{index-CiKbhMfN.js → index-DNjdlOwh.js} +1 -1
  33. package/dist/assets/{index-CY_NzVPm.js → index-DiZy3nuB.js} +1 -1
  34. package/dist/assets/{main-D5j8Ul0m.js → main-DDwa0x9e.js} +9 -9
  35. package/dist/assets/{web-BvAQVbIF.js → web-Dj2DYQG1.js} +1 -1
  36. package/dist/assets/{web-DMxl9AVF.js → web-EBdZk56y.js} +1 -1
  37. package/dist/assets/{web-Cd0-XOkz.js → web-PXjd4vZ8.js} +1 -1
  38. package/dist/build-info.json +1 -1
  39. package/dist/index.html +1 -1
  40. package/dist/locales/en/config.json +1 -0
  41. package/dist/src/packages/server/opencode/json-event-parser.js +3 -1
  42. package/dist/src/packages/server/opencode/server/opencode-server-event-adapter.js +127 -0
  43. package/dist/src/packages/server/opencode/server/opencode-server-process.js +285 -0
  44. package/dist/src/packages/server/opencode/server/opencode-server-recovery-store.js +73 -0
  45. package/dist/src/packages/server/opencode/server/opencode-server-runner.js +384 -0
  46. package/dist/src/packages/server/routes/agents.js +29 -1
  47. package/dist/src/packages/server/runtime/opencode-runner-router.js +93 -0
  48. package/dist/src/packages/server/runtime/opencode-runtime-provider.js +5 -3
  49. package/dist/src/packages/server/services/system-prompt-service.js +49 -1
  50. package/package.json +1 -1
  51. package/dist/assets/index-C34l72MW.js +0 -1
@@ -1 +1 @@
1
- import{df as a}from"./main-D5j8Ul0m.js";import{ImpactStyle as i,NotificationType as r}from"./index-CnESn83U.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{df as a}from"./main-DDwa0x9e.js";import{ImpactStyle as i,NotificationType as r}from"./index-C7t_6aP3.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{df as s}from"./main-D5j8Ul0m.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{df as s}from"./main-DDwa0x9e.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{df as t}from"./main-D5j8Ul0m.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{df as t}from"./main-DDwa0x9e.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
- {"version":"1.154.0"}
1
+ {"version":"1.155.0"}
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
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-D5j8Ul0m.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-DDwa0x9e.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
28
  <link rel="stylesheet" crossorigin href="/assets/main-BHBs2E1L.css">
@@ -38,6 +38,7 @@
38
38
  "tmuxMode": "Tmux Process Persistence",
39
39
  "interactiveMode": "Interactive TUI Mode (Experimental)",
40
40
  "codexAppServerMode": "Codex Streaming / app-server (Experimental)",
41
+ "opencodeServerMode": "OpenCode Streaming / serve (Experimental)",
41
42
  "tts": "Text-to-Speech",
42
43
  "stt": "Speech-to-Text",
43
44
  "externalEditor": "External Editor",
@@ -55,7 +55,9 @@ export class OpencodeJsonEventParser {
55
55
  if (!event || typeof event !== 'object' || !event.type) {
56
56
  return [];
57
57
  }
58
- log.log(`parseEvent: type=${event.type}, sessionID=${event.sessionID || 'none'}`);
58
+ // debug-level: in `opencode serve` mode this fires once per token delta, so
59
+ // keep it out of the default log stream to avoid per-token log spam.
60
+ log.debug(`parseEvent: type=${event.type}, sessionID=${event.sessionID || 'none'}`);
59
61
  switch (event.type) {
60
62
  case 'step_start':
61
63
  return this.parseStepStart(event);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * OpencodeServerEventAdapter
3
+ *
4
+ * Translates `opencode serve` SSE events into Tide StandardEvents, one adapter
5
+ * per agent. Text/reasoning streaming and turn-end finalization are delegated to
6
+ * the existing OpencodeJsonEventParser (its `message.part.delta` /
7
+ * `message.part.updated` handling already matches the server's `properties`
8
+ * shape exactly), so the streaming behaviour is identical to the exec path.
9
+ *
10
+ * The adapter only adds what the server surfaces differently from `opencode run`:
11
+ * - tool parts (`part.type === 'tool'`) → tool_start (once) + tool_result (on
12
+ * completion), since the server streams them as message.part.updated rather
13
+ * than the `tool_use` events the parser expects;
14
+ * - `session.idle` → a synthetic `step_finish` fed through the parser so its
15
+ * stream-finalization + step_complete/resultText logic is reused verbatim;
16
+ * - assistant token/cost capture from `message.updated`, injected into that
17
+ * synthetic step_finish for context tracking.
18
+ */
19
+ import { OpencodeJsonEventParser } from '../json-event-parser.js';
20
+ const TOOL_NAME_MAP = {
21
+ bash: 'Bash', read: 'Read', write: 'Write', edit: 'Edit', glob: 'Glob', grep: 'Grep',
22
+ task: 'Task', agent: 'Agent', skill: 'Skill', webfetch: 'WebFetch', websearch: 'WebSearch',
23
+ todowrite: 'TodoWrite', notebookedit: 'NotebookEdit', askuserquestion: 'AskUserQuestion',
24
+ };
25
+ function normalizeToolName(raw) {
26
+ return TOOL_NAME_MAP[raw.toLowerCase()] || raw;
27
+ }
28
+ export class OpencodeServerEventAdapter {
29
+ parser = new OpencodeJsonEventParser();
30
+ toolStarted = new Set();
31
+ toolInputSent = new Set();
32
+ toolDone = new Set();
33
+ lastTokens;
34
+ lastCost;
35
+ handle(event) {
36
+ const type = event.type;
37
+ if (!type)
38
+ return [];
39
+ const props = event.properties || {};
40
+ switch (type) {
41
+ case 'message.part.delta':
42
+ return this.parser.parseEvent(event);
43
+ case 'message.part.updated': {
44
+ const part = props.part || undefined;
45
+ const out = [];
46
+ // Text/reasoning parts stream through the shared parser (tool parts have
47
+ // no `text`, so the parser safely ignores them).
48
+ out.push(...this.parser.parseEvent(event));
49
+ if (part?.type === 'tool')
50
+ out.push(...this.handleToolPart(part));
51
+ return out;
52
+ }
53
+ case 'message.updated': {
54
+ const info = props.info;
55
+ if (info?.role === 'assistant') {
56
+ if (info.tokens)
57
+ this.lastTokens = info.tokens;
58
+ if (typeof info.cost === 'number')
59
+ this.lastCost = info.cost;
60
+ }
61
+ return [];
62
+ }
63
+ case 'session.idle':
64
+ return this.finalizeTurn();
65
+ case 'session.error': {
66
+ const err = props.error;
67
+ const errorMessage = err?.message || err?.data?.message || 'OpenCode session error';
68
+ return [{ type: 'error', errorMessage }];
69
+ }
70
+ default:
71
+ return [];
72
+ }
73
+ }
74
+ handleToolPart(part) {
75
+ const callId = part.callID || part.id;
76
+ if (!callId)
77
+ return [];
78
+ const toolName = normalizeToolName(part.tool || 'unknown');
79
+ const status = part.state?.status;
80
+ const input = part.state?.input;
81
+ const hasInput = !!input && Object.keys(input).length > 0;
82
+ const events = [];
83
+ if (!this.toolStarted.has(callId)) {
84
+ this.toolStarted.add(callId);
85
+ if (hasInput)
86
+ this.toolInputSent.add(callId);
87
+ events.push({ type: 'tool_start', toolName, toolInput: input, uuid: callId, toolUseId: callId });
88
+ }
89
+ else if (!this.toolInputSent.has(callId) && hasInput) {
90
+ // Input arrived after the first (pending) update — refresh the same row.
91
+ this.toolInputSent.add(callId);
92
+ events.push({ type: 'tool_start', toolName, toolInput: input, uuid: callId, toolUseId: callId });
93
+ }
94
+ if ((status === 'completed' || status === 'error') && !this.toolDone.has(callId)) {
95
+ this.toolDone.add(callId);
96
+ events.push({ type: 'tool_result', toolName, toolOutput: part.state?.output || '', uuid: callId, toolUseId: callId });
97
+ }
98
+ return events;
99
+ }
100
+ finalizeTurn() {
101
+ // Feed a synthetic step_finish so the parser finalizes any open streaming
102
+ // rows and emits usage_snapshot + step_complete (with resultText from the
103
+ // assistant text it accumulated). Reset per-turn tool tracking after.
104
+ const syntheticStepFinish = {
105
+ type: 'step_finish',
106
+ part: {
107
+ reason: 'stop',
108
+ cost: this.lastCost,
109
+ tokens: this.lastTokens
110
+ ? {
111
+ input: this.lastTokens.input || 0,
112
+ output: this.lastTokens.output || 0,
113
+ reasoning: this.lastTokens.reasoning || 0,
114
+ cache: { read: this.lastTokens.cache?.read || 0, write: this.lastTokens.cache?.write || 0 },
115
+ }
116
+ : undefined,
117
+ },
118
+ };
119
+ const events = this.parser.parseEvent(syntheticStepFinish);
120
+ this.toolStarted.clear();
121
+ this.toolInputSent.clear();
122
+ this.toolDone.clear();
123
+ this.lastTokens = undefined;
124
+ this.lastCost = undefined;
125
+ return events;
126
+ }
127
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * OpencodeServerProcess
3
+ *
4
+ * Manages a DETACHED, restart-surviving `opencode serve` HTTP server and speaks
5
+ * its API: one SSE stream (`GET /event`) carries every session's events
6
+ * (word-by-word `message.part.delta`), and prompts/sessions/aborts go over plain
7
+ * HTTP. One server multiplexes every OpenCode agent — each agent owns a
8
+ * `sessionID`, and every event carries `properties.sessionID` so the runner can
9
+ * fan them back out.
10
+ *
11
+ * Why detached + HTTP/SSE (vs `opencode run` per turn): `opencode run --format
12
+ * json` emits one full assistant message (no token deltas), and a child process
13
+ * dies with the commander. A detached server bound to a localhost port survives
14
+ * commander restarts and keeps running any in-flight turn; on restart the runner
15
+ * reconnects the SSE stream and OpenCode's persisted sessions resume seamlessly.
16
+ */
17
+ import { spawn } from 'child_process';
18
+ import * as http from 'http';
19
+ import * as net from 'net';
20
+ import * as fs from 'fs';
21
+ import { createLogger } from '../../utils/logger.js';
22
+ import { loadDaemonInfo, saveDaemonInfo, clearDaemonInfo, OPENCODE_SERVER_LOG_FILE, } from './opencode-server-recovery-store.js';
23
+ const log = createLogger('OpencodeServer');
24
+ const HEALTH_TIMEOUT_MS = 25000;
25
+ const HEALTH_POLL_MS = 300;
26
+ const SSE_RECONNECT_MS = 1000;
27
+ export class OpencodeServerProcess {
28
+ opts;
29
+ port = null;
30
+ sseReq = null;
31
+ stopped = false;
32
+ connected = false;
33
+ reconnectedToExisting = false;
34
+ constructor(opts) {
35
+ this.opts = opts;
36
+ }
37
+ isAlive() {
38
+ return this.connected && !this.stopped;
39
+ }
40
+ didReconnect() {
41
+ return this.reconnectedToExisting;
42
+ }
43
+ get baseUrl() {
44
+ return `http://127.0.0.1:${this.port}`;
45
+ }
46
+ async ensureStarted() {
47
+ if (this.connected)
48
+ return;
49
+ this.stopped = false;
50
+ // 1. Rejoin an already-running daemon if healthy.
51
+ const existing = loadDaemonInfo();
52
+ if (existing && isPidAlive(existing.pid) && (await probeHealth(existing.port, 1500))) {
53
+ log.log(`🔗 Rejoining existing opencode server pid=${existing.pid} port=${existing.port}`);
54
+ this.port = existing.port;
55
+ this.reconnectedToExisting = true;
56
+ this.openSse();
57
+ this.connected = true;
58
+ return;
59
+ }
60
+ // 2. Spawn a fresh detached daemon.
61
+ this.reconnectedToExisting = false;
62
+ const port = await findFreePort();
63
+ const pid = this.spawnDaemon(port);
64
+ saveDaemonInfo({ port, pid, startedAt: Date.now() });
65
+ this.port = port;
66
+ await this.waitForHealth(port);
67
+ this.openSse();
68
+ this.connected = true;
69
+ }
70
+ spawnDaemon(port) {
71
+ const { executablePath, cwd, extraEnv } = this.opts;
72
+ const logFd = (() => {
73
+ try {
74
+ return fs.openSync(OPENCODE_SERVER_LOG_FILE, 'a');
75
+ }
76
+ catch {
77
+ return 'ignore';
78
+ }
79
+ })();
80
+ log.log(`🚀 Spawning detached opencode server: ${executablePath} serve --port ${port}`);
81
+ const child = spawn(executablePath, ['serve', '--port', String(port), '--hostname', '127.0.0.1'], {
82
+ cwd,
83
+ env: { ...process.env, ...extraEnv },
84
+ detached: true,
85
+ stdio: ['ignore', logFd, logFd],
86
+ });
87
+ child.unref();
88
+ child.on('error', (err) => log.error(`opencode serve spawn error: ${err.message}`));
89
+ return child.pid ?? -1;
90
+ }
91
+ async waitForHealth(port) {
92
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS;
93
+ while (Date.now() < deadline) {
94
+ if (await probeHealth(port, 1000))
95
+ return;
96
+ await sleep(HEALTH_POLL_MS);
97
+ }
98
+ throw new Error('opencode server /api/health never became healthy');
99
+ }
100
+ /** Open the single global SSE stream and parse `data:` frames. */
101
+ openSse() {
102
+ if (this.stopped)
103
+ return;
104
+ const req = http.get(`${this.baseUrl}/event`, { headers: { Accept: 'text/event-stream' } }, (res) => {
105
+ res.setEncoding('utf8');
106
+ let buffer = '';
107
+ res.on('data', (chunk) => {
108
+ buffer += chunk;
109
+ // SSE frames are separated by a blank line.
110
+ let idx;
111
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
112
+ const frame = buffer.slice(0, idx);
113
+ buffer = buffer.slice(idx + 2);
114
+ this.handleFrame(frame);
115
+ }
116
+ });
117
+ res.on('end', () => this.handleSseClosed());
118
+ });
119
+ this.sseReq = req;
120
+ req.on('error', (err) => {
121
+ log.warn(`SSE connection error: ${err.message}`);
122
+ this.handleSseClosed();
123
+ });
124
+ }
125
+ handleFrame(frame) {
126
+ // A frame is one or more lines; take the `data:` payload(s).
127
+ const dataLines = frame
128
+ .split('\n')
129
+ .filter((l) => l.startsWith('data:'))
130
+ .map((l) => l.slice(5).trim());
131
+ if (dataLines.length === 0)
132
+ return;
133
+ const payload = dataLines.join('\n');
134
+ if (!payload)
135
+ return;
136
+ let evt;
137
+ try {
138
+ evt = JSON.parse(payload);
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ this.opts.onEvent(evt);
144
+ }
145
+ handleSseClosed() {
146
+ this.sseReq = null;
147
+ if (this.stopped)
148
+ return;
149
+ // Try to re-establish once; if the server is truly gone, give up and notify.
150
+ log.warn('SSE stream closed; attempting to reconnect');
151
+ setTimeout(async () => {
152
+ if (this.stopped || !this.port)
153
+ return;
154
+ if (await probeHealth(this.port, 1500)) {
155
+ this.openSse();
156
+ }
157
+ else {
158
+ this.connected = false;
159
+ this.opts.onDisconnect();
160
+ }
161
+ }, SSE_RECONNECT_MS);
162
+ }
163
+ // ---- HTTP helpers --------------------------------------------------------
164
+ async httpJson(method, path, body) {
165
+ const res = await fetch(`${this.baseUrl}${path}`, {
166
+ method,
167
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
168
+ body: body !== undefined ? JSON.stringify(body) : undefined,
169
+ });
170
+ if (!res.ok) {
171
+ throw new Error(`${method} ${path} → ${res.status} ${res.statusText}`);
172
+ }
173
+ const text = await res.text();
174
+ return text ? JSON.parse(text) : null;
175
+ }
176
+ /** Create a session (optionally scoped to a working directory). */
177
+ async createSession(directory) {
178
+ const body = {};
179
+ if (directory)
180
+ body.location = { directory };
181
+ const resp = await this.httpJson('POST', '/api/session', body);
182
+ const id = resp?.data?.id ?? resp?.id;
183
+ if (!id)
184
+ throw new Error('opencode session create returned no id');
185
+ return id;
186
+ }
187
+ /** Send a prompt to a session. Fire-and-forget: streaming arrives via SSE. */
188
+ sendPrompt(sessionId, text, model) {
189
+ const body = { parts: [{ type: 'text', text }] };
190
+ if (model)
191
+ body.model = model;
192
+ return this.httpJson('POST', `/session/${sessionId}/message`, body).then(() => undefined);
193
+ }
194
+ async abort(sessionId) {
195
+ try {
196
+ await this.httpJson('POST', `/session/${sessionId}/abort`, {});
197
+ }
198
+ catch { /* best-effort */ }
199
+ }
200
+ /** Session IDs currently generating (busy), used to detect turns still in flight. */
201
+ async activeSessionIds() {
202
+ try {
203
+ const resp = await this.httpJson('GET', '/api/session/active');
204
+ const list = (resp?.data ?? resp);
205
+ const ids = new Set();
206
+ if (Array.isArray(list)) {
207
+ for (const item of list) {
208
+ if (typeof item === 'string')
209
+ ids.add(item);
210
+ else if (item?.id)
211
+ ids.add(item.id);
212
+ else if (item?.sessionID)
213
+ ids.add(item.sessionID);
214
+ }
215
+ }
216
+ return ids;
217
+ }
218
+ catch {
219
+ return new Set();
220
+ }
221
+ }
222
+ /** Disconnect our client but LEAVE the daemon running (survives restart). */
223
+ disconnect() {
224
+ this.stopped = true;
225
+ this.connected = false;
226
+ if (this.sseReq) {
227
+ try {
228
+ this.sseReq.destroy();
229
+ }
230
+ catch { /* ignore */ }
231
+ this.sseReq = null;
232
+ }
233
+ }
234
+ /** Disconnect AND kill the daemon (used on explicit full shutdown). */
235
+ killDaemon() {
236
+ this.disconnect();
237
+ const info = loadDaemonInfo();
238
+ if (info && isPidAlive(info.pid)) {
239
+ try {
240
+ process.kill(info.pid, 'SIGTERM');
241
+ }
242
+ catch { /* ignore */ }
243
+ }
244
+ clearDaemonInfo();
245
+ }
246
+ }
247
+ function isPidAlive(pid) {
248
+ if (!pid || pid < 1)
249
+ return false;
250
+ try {
251
+ process.kill(pid, 0);
252
+ return true;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ function probeHealth(port, timeoutMs) {
259
+ return new Promise((resolve) => {
260
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: timeoutMs }, (res) => {
261
+ res.resume();
262
+ resolve(res.statusCode === 200);
263
+ });
264
+ req.on('error', () => resolve(false));
265
+ req.on('timeout', () => {
266
+ req.destroy();
267
+ resolve(false);
268
+ });
269
+ });
270
+ }
271
+ function findFreePort() {
272
+ return new Promise((resolve, reject) => {
273
+ const srv = net.createServer();
274
+ srv.unref();
275
+ srv.on('error', reject);
276
+ srv.listen(0, '127.0.0.1', () => {
277
+ const addr = srv.address();
278
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
279
+ srv.close(() => (port ? resolve(port) : reject(new Error('could not allocate a free port'))));
280
+ });
281
+ });
282
+ }
283
+ function sleep(ms) {
284
+ return new Promise((r) => setTimeout(r, ms));
285
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Persistence for the restart-surviving OpenCode `serve` daemon.
3
+ *
4
+ * Mirrors the Codex app-server recovery store. Persists, in a dedicated file:
5
+ * 1. Daemon info — the HTTP port + pid of the detached `opencode serve` process,
6
+ * so a restarted commander probes it and reconnects instead of respawning.
7
+ * 2. Per-agent session state — agentId ↔ OpenCode sessionID + turn state, so we
8
+ * can re-attach the SSE stream and finalize any turn that was mid-flight
9
+ * during the restart.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+ import { createLogger } from '../../utils/logger.js';
15
+ const log = createLogger('OpencodeServerStore');
16
+ const DATA_DIR = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'tide-commander');
17
+ const DAEMON_FILE = path.join(DATA_DIR, 'opencode-server-daemon.json');
18
+ const AGENTS_FILE = path.join(DATA_DIR, 'opencode-server-agents.json');
19
+ export const OPENCODE_SERVER_LOG_FILE = path.join(DATA_DIR, 'opencode-server.log');
20
+ function ensureDataDir() {
21
+ if (!fs.existsSync(DATA_DIR))
22
+ fs.mkdirSync(DATA_DIR, { recursive: true });
23
+ }
24
+ export function loadDaemonInfo() {
25
+ try {
26
+ if (!fs.existsSync(DAEMON_FILE))
27
+ return null;
28
+ const data = JSON.parse(fs.readFileSync(DAEMON_FILE, 'utf-8'));
29
+ if (typeof data.port === 'number' && typeof data.pid === 'number')
30
+ return data;
31
+ }
32
+ catch (err) {
33
+ log.warn(`Failed to load daemon info: ${err.message}`);
34
+ }
35
+ return null;
36
+ }
37
+ export function saveDaemonInfo(info) {
38
+ ensureDataDir();
39
+ try {
40
+ fs.writeFileSync(DAEMON_FILE, JSON.stringify(info, null, 2), 'utf-8');
41
+ }
42
+ catch (err) {
43
+ log.error(`Failed to save daemon info: ${err.message}`);
44
+ }
45
+ }
46
+ export function clearDaemonInfo() {
47
+ try {
48
+ if (fs.existsSync(DAEMON_FILE))
49
+ fs.unlinkSync(DAEMON_FILE);
50
+ }
51
+ catch { /* ignore */ }
52
+ }
53
+ export function loadOpencodeAgents() {
54
+ try {
55
+ if (!fs.existsSync(AGENTS_FILE))
56
+ return [];
57
+ const data = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf-8'));
58
+ return Array.isArray(data) ? data : [];
59
+ }
60
+ catch (err) {
61
+ log.warn(`Failed to load opencode agents: ${err.message}`);
62
+ return [];
63
+ }
64
+ }
65
+ export function saveOpencodeAgents(agents) {
66
+ ensureDataDir();
67
+ try {
68
+ fs.writeFileSync(AGENTS_FILE, JSON.stringify(agents, null, 2), 'utf-8');
69
+ }
70
+ catch (err) {
71
+ log.error(`Failed to save opencode agents: ${err.message}`);
72
+ }
73
+ }