synthos 0.7.1 → 0.8.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 (263) hide show
  1. package/README.md +215 -65
  2. package/default-pages/application.json +1 -0
  3. package/default-pages/json_tools.json +1 -1
  4. package/default-pages/oregon_trail.html +321 -0
  5. package/default-pages/oregon_trail.json +12 -0
  6. package/default-pages/sidebar_page.json +1 -0
  7. package/default-pages/solar_explorer.html +10 -18
  8. package/default-pages/solar_explorer.json +2 -2
  9. package/default-pages/two-panel_page.json +1 -0
  10. package/default-pages/us_map.html +192 -0
  11. package/default-pages/us_map.json +12 -0
  12. package/default-pages/us_map_1850.html +325 -0
  13. package/default-pages/us_map_1850.json +12 -0
  14. package/default-pages/western_cities_1850.html +526 -0
  15. package/default-pages/western_cities_1850.json +12 -0
  16. package/default-themes/{nebula-dawn.css → nebula-dawn.v2.css} +24 -0
  17. package/default-themes/{nebula-dusk.css → nebula-dusk.v2.css} +24 -0
  18. package/dist/agents/a2a/a2aProvider.d.ts +3 -0
  19. package/dist/agents/a2a/a2aProvider.d.ts.map +1 -0
  20. package/dist/agents/a2a/a2aProvider.js +126 -0
  21. package/dist/agents/a2a/a2aProvider.js.map +1 -0
  22. package/dist/agents/discovery.d.ts +30 -0
  23. package/dist/agents/discovery.d.ts.map +1 -0
  24. package/dist/agents/discovery.js +52 -0
  25. package/dist/agents/discovery.js.map +1 -0
  26. package/dist/agents/index.d.ts +7 -0
  27. package/dist/agents/index.d.ts.map +1 -0
  28. package/dist/agents/index.js +19 -0
  29. package/dist/agents/index.js.map +1 -0
  30. package/dist/agents/openclaw/gatewayManager.d.ts +113 -0
  31. package/dist/agents/openclaw/gatewayManager.d.ts.map +1 -0
  32. package/dist/agents/openclaw/gatewayManager.js +470 -0
  33. package/dist/agents/openclaw/gatewayManager.js.map +1 -0
  34. package/dist/agents/openclaw/openclawProvider.d.ts +3 -0
  35. package/dist/agents/openclaw/openclawProvider.d.ts.map +1 -0
  36. package/dist/agents/openclaw/openclawProvider.js +239 -0
  37. package/dist/agents/openclaw/openclawProvider.js.map +1 -0
  38. package/dist/agents/openclaw/sshTunnelManager.d.ts +23 -0
  39. package/dist/agents/openclaw/sshTunnelManager.d.ts.map +1 -0
  40. package/dist/agents/openclaw/sshTunnelManager.js +340 -0
  41. package/dist/agents/openclaw/sshTunnelManager.js.map +1 -0
  42. package/dist/agents/types.d.ts +64 -0
  43. package/dist/agents/types.d.ts.map +1 -0
  44. package/dist/agents/types.js +6 -0
  45. package/dist/agents/types.js.map +1 -0
  46. package/dist/connectors/airtable/connector.json +27 -0
  47. package/dist/connectors/alpha-vantage/connector.json +26 -0
  48. package/dist/connectors/brave-search/connector.json +26 -0
  49. package/dist/connectors/cloudinary/connector.json +27 -0
  50. package/dist/connectors/deepl/connector.json +28 -0
  51. package/dist/connectors/elevenlabs/connector.json +30 -0
  52. package/dist/connectors/giphy/connector.json +27 -0
  53. package/dist/connectors/github/connector.json +29 -0
  54. package/dist/connectors/huggingface/connector.json +27 -0
  55. package/dist/connectors/imgur/connector.json +29 -0
  56. package/dist/connectors/index.d.ts +1 -1
  57. package/dist/connectors/index.d.ts.map +1 -1
  58. package/dist/connectors/instagram/connector.json +43 -0
  59. package/dist/connectors/jira/connector.json +28 -0
  60. package/dist/connectors/mapbox/connector.json +26 -0
  61. package/dist/connectors/nasa/connector.json +27 -0
  62. package/dist/connectors/newsapi/connector.json +27 -0
  63. package/dist/connectors/notion/connector.json +28 -0
  64. package/dist/connectors/open-exchange-rates/connector.json +27 -0
  65. package/dist/connectors/openweathermap/connector.json +26 -0
  66. package/dist/connectors/pexels/connector.json +27 -0
  67. package/dist/connectors/registry.d.ts.map +1 -1
  68. package/dist/connectors/registry.js +42 -96
  69. package/dist/connectors/registry.js.map +1 -1
  70. package/dist/connectors/resend/connector.json +29 -0
  71. package/dist/connectors/rss2json/connector.json +27 -0
  72. package/dist/connectors/sendgrid/connector.json +27 -0
  73. package/dist/connectors/spoonacular/connector.json +28 -0
  74. package/dist/connectors/stability-ai/connector.json +27 -0
  75. package/dist/connectors/twilio/connector.json +28 -0
  76. package/dist/connectors/types.d.ts +23 -0
  77. package/dist/connectors/types.d.ts.map +1 -1
  78. package/dist/connectors/unsplash/connector.json +27 -0
  79. package/dist/connectors/wolfram-alpha/connector.json +26 -0
  80. package/dist/connectors/youtube-data/connector.json +30 -0
  81. package/dist/files.d.ts +1 -0
  82. package/dist/files.d.ts.map +1 -1
  83. package/dist/files.js +16 -1
  84. package/dist/files.js.map +1 -1
  85. package/dist/init.d.ts.map +1 -1
  86. package/dist/init.js +28 -0
  87. package/dist/init.js.map +1 -1
  88. package/dist/migrations.d.ts +3 -2
  89. package/dist/migrations.d.ts.map +1 -1
  90. package/dist/migrations.js +122 -138
  91. package/dist/migrations.js.map +1 -1
  92. package/dist/models/anthropic.d.ts +22 -0
  93. package/dist/models/anthropic.d.ts.map +1 -0
  94. package/dist/models/anthropic.js +76 -0
  95. package/dist/models/anthropic.js.map +1 -0
  96. package/dist/models/chainOfThought.d.ts +12 -0
  97. package/dist/models/chainOfThought.d.ts.map +1 -0
  98. package/dist/models/chainOfThought.js +45 -0
  99. package/dist/models/chainOfThought.js.map +1 -0
  100. package/dist/models/fireworksai.d.ts +30 -0
  101. package/dist/models/fireworksai.d.ts.map +1 -0
  102. package/dist/models/fireworksai.js +133 -0
  103. package/dist/models/fireworksai.js.map +1 -0
  104. package/dist/models/index.d.ts +7 -1
  105. package/dist/models/index.d.ts.map +1 -1
  106. package/dist/models/index.js +19 -1
  107. package/dist/models/index.js.map +1 -1
  108. package/dist/models/logCompletePrompt.d.ts +3 -0
  109. package/dist/models/logCompletePrompt.d.ts.map +1 -0
  110. package/dist/models/logCompletePrompt.js +23 -0
  111. package/dist/models/logCompletePrompt.js.map +1 -0
  112. package/dist/models/openai.d.ts +24 -0
  113. package/dist/models/openai.d.ts.map +1 -0
  114. package/dist/models/openai.js +80 -0
  115. package/dist/models/openai.js.map +1 -0
  116. package/dist/models/providers.d.ts +1 -0
  117. package/dist/models/providers.d.ts.map +1 -1
  118. package/dist/models/providers.js +12 -4
  119. package/dist/models/providers.js.map +1 -1
  120. package/dist/models/types.d.ts +34 -2
  121. package/dist/models/types.d.ts.map +1 -1
  122. package/dist/models/types.js +16 -0
  123. package/dist/models/types.js.map +1 -1
  124. package/dist/models/utils.d.ts +6 -0
  125. package/dist/models/utils.d.ts.map +1 -0
  126. package/dist/models/utils.js +21 -0
  127. package/dist/models/utils.js.map +1 -0
  128. package/dist/scripts.d.ts +2 -1
  129. package/dist/scripts.d.ts.map +1 -1
  130. package/dist/scripts.js +4 -3
  131. package/dist/scripts.js.map +1 -1
  132. package/dist/service/createCompletePrompt.d.ts +1 -1
  133. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  134. package/dist/service/createCompletePrompt.js +9 -6
  135. package/dist/service/createCompletePrompt.js.map +1 -1
  136. package/dist/service/generateImage.d.ts +1 -1
  137. package/dist/service/generateImage.d.ts.map +1 -1
  138. package/dist/service/generateImage.js +3 -3
  139. package/dist/service/generateImage.js.map +1 -1
  140. package/dist/service/server.d.ts.map +1 -1
  141. package/dist/service/server.js +3 -0
  142. package/dist/service/server.js.map +1 -1
  143. package/dist/service/transformPage.d.ts +4 -2
  144. package/dist/service/transformPage.d.ts.map +1 -1
  145. package/dist/service/transformPage.js +74 -6
  146. package/dist/service/transformPage.js.map +1 -1
  147. package/dist/service/useAgentRoutes.d.ts +4 -0
  148. package/dist/service/useAgentRoutes.d.ts.map +1 -0
  149. package/dist/service/useAgentRoutes.js +389 -0
  150. package/dist/service/useAgentRoutes.js.map +1 -0
  151. package/dist/service/useApiRoutes.d.ts.map +1 -1
  152. package/dist/service/useApiRoutes.js +157 -16
  153. package/dist/service/useApiRoutes.js.map +1 -1
  154. package/dist/service/useConnectorRoutes.d.ts.map +1 -1
  155. package/dist/service/useConnectorRoutes.js +14 -3
  156. package/dist/service/useConnectorRoutes.js.map +1 -1
  157. package/dist/service/useGatewayRoutes.d.ts +4 -0
  158. package/dist/service/useGatewayRoutes.d.ts.map +1 -0
  159. package/dist/service/useGatewayRoutes.js +168 -0
  160. package/dist/service/useGatewayRoutes.js.map +1 -0
  161. package/dist/service/usePageRoutes.d.ts.map +1 -1
  162. package/dist/service/usePageRoutes.js +16 -5
  163. package/dist/service/usePageRoutes.js.map +1 -1
  164. package/dist/settings.d.ts +2 -1
  165. package/dist/settings.d.ts.map +1 -1
  166. package/dist/settings.js +4 -8
  167. package/dist/settings.js.map +1 -1
  168. package/dist/themes.d.ts +14 -0
  169. package/dist/themes.d.ts.map +1 -1
  170. package/dist/themes.js +86 -13
  171. package/dist/themes.js.map +1 -1
  172. package/package.json +10 -5
  173. package/page-scripts/helpers-v2.js +222 -0
  174. package/page-scripts/page-v2.js +656 -0
  175. package/required-pages/builder.html +1 -27
  176. package/required-pages/pages.html +745 -22
  177. package/required-pages/settings.html +819 -21
  178. package/required-pages/synthos_apis.html +56 -1
  179. package/src/agents/a2a/a2aProvider.ts +110 -0
  180. package/src/agents/discovery.ts +74 -0
  181. package/src/agents/index.ts +6 -0
  182. package/src/agents/openclaw/gatewayManager.ts +559 -0
  183. package/src/agents/openclaw/openclawProvider.ts +261 -0
  184. package/src/agents/openclaw/sshTunnelManager.ts +385 -0
  185. package/src/agents/types.ts +82 -0
  186. package/src/connectors/airtable/connector.json +27 -0
  187. package/src/connectors/alpha-vantage/connector.json +26 -0
  188. package/src/connectors/brave-search/connector.json +26 -0
  189. package/src/connectors/cloudinary/connector.json +27 -0
  190. package/src/connectors/deepl/connector.json +28 -0
  191. package/src/connectors/elevenlabs/connector.json +30 -0
  192. package/src/connectors/giphy/connector.json +27 -0
  193. package/src/connectors/github/connector.json +29 -0
  194. package/src/connectors/huggingface/connector.json +27 -0
  195. package/src/connectors/imgur/connector.json +29 -0
  196. package/src/connectors/index.ts +2 -0
  197. package/src/connectors/instagram/connector.json +43 -0
  198. package/src/connectors/jira/connector.json +28 -0
  199. package/src/connectors/mapbox/connector.json +26 -0
  200. package/src/connectors/nasa/connector.json +27 -0
  201. package/src/connectors/newsapi/connector.json +27 -0
  202. package/src/connectors/notion/connector.json +28 -0
  203. package/src/connectors/open-exchange-rates/connector.json +27 -0
  204. package/src/connectors/openweathermap/connector.json +26 -0
  205. package/src/connectors/pexels/connector.json +27 -0
  206. package/src/connectors/registry.ts +21 -97
  207. package/src/connectors/resend/connector.json +29 -0
  208. package/src/connectors/rss2json/connector.json +27 -0
  209. package/src/connectors/sendgrid/connector.json +27 -0
  210. package/src/connectors/spoonacular/connector.json +28 -0
  211. package/src/connectors/stability-ai/connector.json +27 -0
  212. package/src/connectors/twilio/connector.json +28 -0
  213. package/src/connectors/types.ts +25 -0
  214. package/src/connectors/unsplash/connector.json +27 -0
  215. package/src/connectors/wolfram-alpha/connector.json +26 -0
  216. package/src/connectors/youtube-data/connector.json +30 -0
  217. package/src/files.ts +14 -0
  218. package/src/init.ts +27 -0
  219. package/src/migrations.ts +121 -138
  220. package/src/models/anthropic.ts +89 -0
  221. package/src/models/chainOfThought.ts +56 -0
  222. package/src/models/fireworksai.ts +136 -0
  223. package/src/models/index.ts +7 -1
  224. package/src/models/logCompletePrompt.ts +25 -0
  225. package/src/models/openai.ts +90 -0
  226. package/src/models/providers.ts +12 -3
  227. package/src/models/types.ts +67 -2
  228. package/src/models/utils.ts +16 -0
  229. package/src/scripts.ts +2 -2
  230. package/src/service/createCompletePrompt.ts +3 -1
  231. package/src/service/generateImage.ts +2 -2
  232. package/src/service/server.ts +4 -0
  233. package/src/service/transformPage.ts +81 -8
  234. package/src/service/useAgentRoutes.ts +423 -0
  235. package/src/service/useApiRoutes.ts +173 -18
  236. package/src/service/useConnectorRoutes.ts +14 -3
  237. package/src/service/usePageRoutes.ts +20 -6
  238. package/src/settings.ts +6 -10
  239. package/src/themes.ts +84 -12
  240. package/tests/README.md +12 -0
  241. package/tests/anthropic.spec.ts +84 -0
  242. package/tests/chainOfThought.spec.ts +108 -0
  243. package/tests/ensureScripts.spec.ts +82 -0
  244. package/tests/files.spec.ts +233 -0
  245. package/tests/fireworksai.spec.ts +92 -0
  246. package/tests/logCompletePrompt.spec.ts +74 -0
  247. package/tests/migrations.spec.ts +169 -0
  248. package/tests/openai.spec.ts +71 -0
  249. package/tests/pages.spec.ts +328 -0
  250. package/tests/providers.spec.ts +144 -0
  251. package/tests/scripts.spec.ts +209 -0
  252. package/tests/transformPage.spec.ts +931 -0
  253. package/tests/types.spec.ts +23 -0
  254. package/default-pages/app_builder.json +0 -1
  255. package/default-pages/sidebar_builder.json +0 -1
  256. package/default-pages/two-panel_builder.json +0 -1
  257. package/images/home.png +0 -0
  258. package/images/page-management.png +0 -0
  259. package/images/settings.png +0 -0
  260. package/images/synthos-square.png +0 -0
  261. /package/default-pages/{app_builder.html → application.html} +0 -0
  262. /package/default-pages/{sidebar_builder.html → sidebar_page.html} +0 -0
  263. /package/default-pages/{two-panel_builder.html → two-panel_page.html} +0 -0
@@ -0,0 +1,261 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { AgentConfig, AgentResponse, AgentEvent, AgentProvider, Attachment, ChatMessage } from '../types';
3
+ import { connectAgent, GatewayConnection, request, onEvent, offEvent } from './gatewayManager';
4
+
5
+ /**
6
+ * OpenClaw provider that communicates over the gateway WebSocket.
7
+ *
8
+ * Chat flow:
9
+ * 1. Ensure WebSocket connection (connectAgent)
10
+ * 2. Resolve the main session key (sessions.resolve)
11
+ * 3. Send message via chat.send RPC → returns { runId, status: "started" }
12
+ * 4. Listen for "chat" events with matching runId for streamed response
13
+ */
14
+
15
+ /** Get or create a WebSocket connection for an agent. */
16
+ async function getConnection(agent: AgentConfig): Promise<GatewayConnection> {
17
+ if (!agent.token) throw new Error(`Agent "${agent.name}" has no token configured`);
18
+ return connectAgent({ id: agent.id, name: agent.name, url: agent.url, token: agent.token });
19
+ }
20
+
21
+ /** Get the session key from agent config or resolve it. */
22
+ async function resolveSessionKey(agent: AgentConfig, conn: GatewayConnection): Promise<string> {
23
+ if (agent.sessionKey) return agent.sessionKey;
24
+ const result = await request(conn, 'sessions.resolve', {}) as Record<string, unknown>;
25
+ if (!result.key || typeof result.key !== 'string') {
26
+ throw new Error('Failed to resolve session key — set a Default Session Key in agent settings');
27
+ }
28
+ return result.key;
29
+ }
30
+
31
+ /**
32
+ * Filter out noise from chat history — heartbeats, tool output, media refs, etc.
33
+ * Returns true if the message should be excluded.
34
+ */
35
+ function isNoiseMessage(role: string, content: string): boolean {
36
+ const trimmed = content.trim();
37
+ if (!trimmed) return true;
38
+
39
+ // Heartbeat prompts (user) and responses (assistant)
40
+ if (content.includes('"conversation_label"') && content.includes('heartbeat')) return true;
41
+ if (trimmed === 'HEARTBEAT_OK') return true;
42
+
43
+ // Raw JSON tool output (starts with { and looks like a JSON blob)
44
+ if (trimmed.startsWith('{') && (
45
+ trimmed.includes('"targetId"') ||
46
+ trimmed.includes('"ok"') ||
47
+ trimmed.includes('"error"') ||
48
+ trimmed.includes('"url"') ||
49
+ trimmed.includes('"format"')
50
+ )) return true;
51
+
52
+ // OpenClaw security notice / browser content injection
53
+ if (trimmed.startsWith('SECURITY NOTICE:')) return true;
54
+
55
+ // Media file references
56
+ if (trimmed.startsWith('MEDIA:')) return true;
57
+
58
+ return false;
59
+ }
60
+
61
+ export const openclawProvider: AgentProvider = {
62
+ async send(agent: AgentConfig, message: string, attachments?: Attachment[]): Promise<AgentResponse> {
63
+ const conn = await getConnection(agent);
64
+ const sessionKey = await resolveSessionKey(agent, conn);
65
+
66
+ // Collect response text from chat events
67
+ let responseText = '';
68
+ let done = false;
69
+
70
+ const chatListener = (payload: unknown) => {
71
+ const evt = payload as Record<string, unknown>;
72
+ const state = evt.state as string | undefined;
73
+ const msg = evt.message as Record<string, unknown> | undefined;
74
+
75
+ if (msg?.content && Array.isArray(msg.content)) {
76
+ for (const part of msg.content) {
77
+ const p = part as Record<string, unknown>;
78
+ if (p.type === 'text' && typeof p.text === 'string') {
79
+ responseText = p.text;
80
+ }
81
+ }
82
+ }
83
+
84
+ if (state === 'final' || state === 'error') {
85
+ done = true;
86
+ }
87
+ };
88
+
89
+ onEvent(conn, 'chat', chatListener);
90
+
91
+ try {
92
+ // Send the message — returns immediately with { runId, status: "started" }
93
+ await request(conn, 'chat.send', {
94
+ sessionKey,
95
+ message,
96
+ idempotencyKey: randomUUID(),
97
+ ...(attachments?.length ? { attachments } : {}),
98
+ }, { expectFinal: false });
99
+
100
+ // Wait for the chat to complete (poll with timeout)
101
+ const timeout = 60_000;
102
+ const start = Date.now();
103
+ while (!done && Date.now() - start < timeout) {
104
+ await new Promise(r => setTimeout(r, 100));
105
+ }
106
+
107
+ return {
108
+ kind: 'message',
109
+ text: responseText || undefined,
110
+ raw: { sessionKey, text: responseText },
111
+ };
112
+ } finally {
113
+ offEvent(conn, 'chat', chatListener);
114
+ }
115
+ },
116
+
117
+ async *sendStream(agent: AgentConfig, message: string, attachments?: Attachment[]): AsyncIterable<AgentEvent> {
118
+ const conn = await getConnection(agent);
119
+ const sessionKey = await resolveSessionKey(agent, conn);
120
+
121
+ // Set up an event queue for streaming
122
+ const queue: AgentEvent[] = [];
123
+ let done = false;
124
+ let resolveWait: (() => void) | null = null;
125
+
126
+ // Use agent events for token-by-token streaming deltas,
127
+ // and chat events for final state detection.
128
+ const agentListener = (payload: unknown) => {
129
+ const evt = payload as Record<string, unknown>;
130
+ const stream = evt.stream as string | undefined;
131
+ const data = evt.data as Record<string, unknown> | undefined;
132
+
133
+ if (stream === 'assistant' && data?.delta && typeof data.delta === 'string') {
134
+ queue.push({ kind: 'text', data: data.delta });
135
+ }
136
+
137
+ if (stream === 'lifecycle' && data?.phase === 'end') {
138
+ queue.push({ kind: 'done', data: null });
139
+ done = true;
140
+ }
141
+
142
+ if (resolveWait) { resolveWait(); resolveWait = null; }
143
+ };
144
+
145
+ const chatListener = (payload: unknown) => {
146
+ const evt = payload as Record<string, unknown>;
147
+ const state = evt.state as string | undefined;
148
+
149
+ if (state === 'error') {
150
+ const errorMsg = (evt.error as Record<string, unknown>)?.message ?? 'Unknown error';
151
+ queue.push({ kind: 'error', data: errorMsg });
152
+ done = true;
153
+ if (resolveWait) { resolveWait(); resolveWait = null; }
154
+ }
155
+ };
156
+
157
+ onEvent(conn, 'agent', agentListener);
158
+ onEvent(conn, 'chat', chatListener);
159
+
160
+ try {
161
+ // Send the message
162
+ await request(conn, 'chat.send', {
163
+ sessionKey,
164
+ message,
165
+ idempotencyKey: randomUUID(),
166
+ ...(attachments?.length ? { attachments } : {}),
167
+ }, { expectFinal: false });
168
+
169
+ // Yield events as they arrive
170
+ const timeout = 60_000;
171
+ const start = Date.now();
172
+ while (!done && Date.now() - start < timeout) {
173
+ if (queue.length === 0) {
174
+ await new Promise<void>(r => {
175
+ resolveWait = r;
176
+ setTimeout(r, 500);
177
+ });
178
+ }
179
+
180
+ while (queue.length > 0) {
181
+ yield queue.shift()!;
182
+ }
183
+ }
184
+
185
+ if (!done) {
186
+ yield { kind: 'error', data: 'Chat response timed out' };
187
+ }
188
+ } finally {
189
+ offEvent(conn, 'agent', agentListener);
190
+ offEvent(conn, 'chat', chatListener);
191
+ }
192
+ },
193
+
194
+ supportsStreaming(): boolean {
195
+ return true;
196
+ },
197
+
198
+ async getHistory(agent: AgentConfig): Promise<ChatMessage[]> {
199
+ const conn = await getConnection(agent);
200
+ const sessionKey = await resolveSessionKey(agent, conn);
201
+ const result = await request(conn, 'chat.history', { sessionKey });
202
+ // Discovery log removed — response is too large for routine logging
203
+
204
+ // Normalize: expect result to have a messages array (adjust once we see the real shape)
205
+ const raw = result as Record<string, unknown>;
206
+ const items = (raw.messages ?? raw.items ?? raw.history ?? []) as unknown[];
207
+ const messages: ChatMessage[] = [];
208
+ for (const item of items) {
209
+ const m = item as Record<string, unknown>;
210
+ const rawRole = m.role as string ?? 'assistant';
211
+
212
+ // Skip toolCall / toolResult pairs entirely — not user-visible
213
+ if (rawRole === 'toolResult' || rawRole === 'tool') continue;
214
+ if (rawRole === 'assistant' && Array.isArray(m.content)) {
215
+ const hasOnlyToolCalls = (m.content as Record<string, unknown>[]).every(
216
+ p => p.type === 'toolCall' || p.type === 'tool_use'
217
+ );
218
+ if (hasOnlyToolCalls) continue;
219
+ }
220
+
221
+ const role: 'user' | 'assistant' = rawRole === 'user' ? 'user' : 'assistant';
222
+
223
+ // Extract text from content array if present
224
+ let content = '';
225
+ if (typeof m.content === 'string') {
226
+ content = m.content;
227
+ } else if (Array.isArray(m.content)) {
228
+ for (const part of m.content) {
229
+ const p = part as Record<string, unknown>;
230
+ if (p.type === 'text' && typeof p.text === 'string') {
231
+ content += p.text;
232
+ }
233
+ }
234
+ }
235
+
236
+ if (isNoiseMessage(role, content)) continue;
237
+
238
+ messages.push({
239
+ role,
240
+ content,
241
+ timestamp: m.timestamp as number | undefined,
242
+ raw: m,
243
+ });
244
+ }
245
+ return messages;
246
+ },
247
+
248
+ async abortChat(agent: AgentConfig): Promise<void> {
249
+ const conn = await getConnection(agent);
250
+ const sessionKey = await resolveSessionKey(agent, conn);
251
+ const result = await request(conn, 'chat.abort', { sessionKey });
252
+ console.log('[OpenClaw] ← raw chat.abort:', JSON.stringify(result, null, 2));
253
+ },
254
+
255
+ async clearSession(agent: AgentConfig): Promise<void> {
256
+ const conn = await getConnection(agent);
257
+ const sessionKey = await resolveSessionKey(agent, conn);
258
+ const result = await request(conn, 'sessions.reset', { sessionKey });
259
+ console.log('[OpenClaw] ← raw sessions.reset:', JSON.stringify(result, null, 2));
260
+ },
261
+ };
@@ -0,0 +1,385 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import * as net from 'net';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { randomBytes } from 'crypto';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface SshTunnelConfig {
13
+ /** Full SSH command, e.g. "ssh -p 22 -N -L 18789:127.0.0.1:43901 root@1.2.3.4" */
14
+ command: string;
15
+ /** Password to pipe when prompted */
16
+ password: string;
17
+ }
18
+
19
+ interface TunnelEntry {
20
+ agentId: string;
21
+ config: SshTunnelConfig;
22
+ process: ChildProcess | null;
23
+ running: boolean;
24
+ reconnecting: boolean;
25
+ intentionalStop: boolean;
26
+ reconnectTimer: ReturnType<typeof setTimeout> | null;
27
+ reconnectAttempts: number;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const TUNNEL_READY_PROBE_DELAY_MS = 2_000;
35
+ const BASE_RECONNECT_DELAY_MS = 2_000;
36
+ const MAX_RECONNECT_DELAY_MS = 120_000;
37
+
38
+ const IS_WINDOWS = process.platform === 'win32';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Shared askpass script — reads password from an env var, never touches disk
42
+ // with the actual secret. One script is created per process lifetime and
43
+ // reused by all tunnels. Each tunnel sets a unique env var name so concurrent
44
+ // tunnels don't collide.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ let sharedAskpassPath: string | null = null;
48
+
49
+ /**
50
+ * Env-var-based askpass script. The script reads the env var whose name is
51
+ * passed via SYNTHOS_SSH_PASS_VAR and echoes its value.
52
+ *
53
+ * Windows .bat: echo %<varName>%
54
+ * Unix .sh: eval echo \$<varName>
55
+ */
56
+ function getAskpassScript(): string {
57
+ if (sharedAskpassPath && fs.existsSync(sharedAskpassPath)) {
58
+ return sharedAskpassPath;
59
+ }
60
+
61
+ const tmpDir = os.tmpdir();
62
+ if (IS_WINDOWS) {
63
+ const p = path.join(tmpDir, 'synthos-askpass.bat');
64
+ // %SYNTHOS_SSH_PASS_VAR% holds the NAME of the var containing the password
65
+ // We use delayed expansion to read it indirectly
66
+ fs.writeFileSync(p,
67
+ '@echo off\r\n' +
68
+ 'setlocal enabledelayedexpansion\r\n' +
69
+ 'echo !%SYNTHOS_SSH_PASS_VAR%!\r\n',
70
+ { mode: 0o700 }
71
+ );
72
+ sharedAskpassPath = p;
73
+ } else {
74
+ const p = path.join(tmpDir, 'synthos-askpass.sh');
75
+ fs.writeFileSync(p,
76
+ '#!/bin/sh\n' +
77
+ 'eval echo \\$"$SYNTHOS_SSH_PASS_VAR"\n',
78
+ { mode: 0o700 }
79
+ );
80
+ sharedAskpassPath = p;
81
+ }
82
+
83
+ return sharedAskpassPath;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Module-level tunnel pool
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const tunnels = new Map<string, TunnelEntry>();
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Public API
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Start an SSH tunnel for the given agent. Resolves once the tunnel appears
98
+ * ready (local port probe succeeds or delay elapses).
99
+ */
100
+ export async function startTunnel(agentId: string, config: SshTunnelConfig): Promise<void> {
101
+ // If already running, nothing to do
102
+ const existing = tunnels.get(agentId);
103
+ if (existing?.running) {
104
+ return;
105
+ }
106
+
107
+ const entry: TunnelEntry = existing ?? {
108
+ agentId,
109
+ config,
110
+ process: null,
111
+ running: false,
112
+ reconnecting: false,
113
+ intentionalStop: false,
114
+ reconnectTimer: null,
115
+ reconnectAttempts: 0,
116
+ };
117
+
118
+ entry.config = config;
119
+ entry.intentionalStop = false;
120
+ tunnels.set(agentId, entry);
121
+
122
+ return spawnTunnel(entry);
123
+ }
124
+
125
+ /**
126
+ * Stop the SSH tunnel for the given agent.
127
+ */
128
+ export function stopTunnel(agentId: string): void {
129
+ const entry = tunnels.get(agentId);
130
+ if (!entry) return;
131
+
132
+ entry.intentionalStop = true;
133
+
134
+ if (entry.reconnectTimer) {
135
+ clearTimeout(entry.reconnectTimer);
136
+ entry.reconnectTimer = null;
137
+ }
138
+
139
+ if (entry.process) {
140
+ entry.process.kill();
141
+ entry.process = null;
142
+ }
143
+
144
+ entry.running = false;
145
+ entry.reconnecting = false;
146
+ tunnels.delete(agentId);
147
+ console.log(`[SSH Tunnel] Stopped tunnel for agent "${agentId}"`);
148
+ }
149
+
150
+ /**
151
+ * Get tunnel status without starting it.
152
+ */
153
+ export function getTunnelStatus(agentId: string): { running: boolean; reconnecting: boolean } {
154
+ const entry = tunnels.get(agentId);
155
+ return {
156
+ running: entry?.running ?? false,
157
+ reconnecting: entry?.reconnecting ?? false,
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Internal helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Try to extract the local port from the SSH command.
167
+ * Looks for -L <localPort>:<host>:<remotePort> pattern.
168
+ */
169
+ function extractLocalPort(command: string): number | null {
170
+ const match = command.match(/-L\s+(\d+):/);
171
+ return match ? parseInt(match[1], 10) : null;
172
+ }
173
+
174
+ /**
175
+ * Probe a local TCP port to check if it's accepting connections.
176
+ */
177
+ function probePort(port: number, timeoutMs: number = 1000): Promise<boolean> {
178
+ return new Promise((resolve) => {
179
+ const socket = new net.Socket();
180
+ const timer = setTimeout(() => {
181
+ socket.destroy();
182
+ resolve(false);
183
+ }, timeoutMs);
184
+
185
+ socket.connect(port, '127.0.0.1', () => {
186
+ clearTimeout(timer);
187
+ socket.destroy();
188
+ resolve(true);
189
+ });
190
+
191
+ socket.on('error', () => {
192
+ clearTimeout(timer);
193
+ socket.destroy();
194
+ resolve(false);
195
+ });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Spawn the SSH process and wait for the tunnel to be ready.
201
+ *
202
+ * Password delivery: SSH does NOT read passwords from stdin — it opens the
203
+ * TTY directly. We use a shared askpass script that reads the password from
204
+ * a per-tunnel env var. The password only lives in process memory (the env
205
+ * of the child process), never on disk.
206
+ */
207
+ function spawnTunnel(entry: TunnelEntry): Promise<void> {
208
+ return new Promise<void>((resolve, reject) => {
209
+ console.log(`[SSH Tunnel] Starting tunnel for agent "${entry.agentId}": ${entry.config.command}`);
210
+
211
+ let settled = false;
212
+
213
+ function settle(err?: Error): void {
214
+ if (settled) return;
215
+ settled = true;
216
+ if (err) reject(err);
217
+ else resolve();
218
+ }
219
+
220
+ try {
221
+ const askpassPath = getAskpassScript();
222
+
223
+ // Per-tunnel env var name so concurrent tunnels don't collide
224
+ const passVarName = `SYNTHOS_SSHPW_${randomBytes(4).toString('hex')}`;
225
+
226
+ // Build args from the user's command, injecting StrictHostKeyChecking=no
227
+ // to avoid the interactive host key confirmation prompt
228
+ const parts = entry.config.command.trim().split(/\s+/);
229
+ const cmd = parts.shift()!;
230
+ const args = [
231
+ '-o', 'StrictHostKeyChecking=no',
232
+ '-o', 'UserKnownHostsFile=' + (IS_WINDOWS ? 'NUL' : '/dev/null'),
233
+ ...parts,
234
+ ];
235
+
236
+ const env: Record<string, string> = {
237
+ ...process.env as Record<string, string>,
238
+ SSH_ASKPASS: askpassPath,
239
+ SSH_ASKPASS_REQUIRE: 'force',
240
+ // The askpass script reads this var name, then echoes its value
241
+ SYNTHOS_SSH_PASS_VAR: passVarName,
242
+ [passVarName]: entry.config.password,
243
+ };
244
+
245
+ // On Unix, DISPLAY must be set for SSH_ASKPASS to be used (fallback for older SSH)
246
+ if (!IS_WINDOWS) {
247
+ env.DISPLAY = env.DISPLAY || 'dummy:0';
248
+ }
249
+
250
+ console.log(`[SSH Tunnel] Spawning: ${cmd} ${args.join(' ')}`);
251
+
252
+ const child = spawn(cmd, args, {
253
+ stdio: ['pipe', 'pipe', 'pipe'],
254
+ env,
255
+ // Detach stdin from the parent's TTY so SSH uses SSH_ASKPASS
256
+ detached: !IS_WINDOWS,
257
+ });
258
+
259
+ entry.process = child;
260
+
261
+ // Log all output for diagnostics
262
+ const handleOutput = (source: string) => (data: Buffer) => {
263
+ const text = data.toString().trim();
264
+ if (text) {
265
+ console.log(`[SSH Tunnel] [${entry.agentId}] ${source}: ${text}`);
266
+ }
267
+ };
268
+
269
+ child.stdout?.on('data', handleOutput('stdout'));
270
+ child.stderr?.on('data', handleOutput('stderr'));
271
+
272
+ child.on('error', (err) => {
273
+ console.error(`[SSH Tunnel] Spawn error for agent "${entry.agentId}":`, err.message);
274
+ entry.running = false;
275
+ entry.process = null;
276
+ settle(err);
277
+ });
278
+
279
+ child.on('exit', (code, signal) => {
280
+ const wasRunning = entry.running;
281
+ entry.running = false;
282
+ entry.process = null;
283
+
284
+ console.log(`[SSH Tunnel] Process exited for agent "${entry.agentId}" (code=${code}, signal=${signal})`);
285
+
286
+ // If we never settled (tunnel never came up), reject
287
+ settle(new Error(`SSH tunnel exited before becoming ready (code=${code})`));
288
+
289
+ // Auto-reconnect if the tunnel was running and this wasn't intentional
290
+ if (wasRunning && !entry.intentionalStop) {
291
+ scheduleReconnect(entry);
292
+ }
293
+ });
294
+
295
+ // Wait for the tunnel to be ready by probing the local port
296
+ const localPort = extractLocalPort(entry.config.command);
297
+ if (localPort) {
298
+ waitForPort(entry, localPort, settle);
299
+ } else {
300
+ // No port to probe — use delay heuristic
301
+ setTimeout(() => {
302
+ if (entry.process && !entry.intentionalStop) {
303
+ entry.running = true;
304
+ entry.reconnecting = false;
305
+ entry.reconnectAttempts = 0;
306
+ console.log(`[SSH Tunnel] Tunnel ready (delay heuristic) for agent "${entry.agentId}"`);
307
+ settle();
308
+ }
309
+ }, TUNNEL_READY_PROBE_DELAY_MS);
310
+ }
311
+ } catch (err) {
312
+ entry.running = false;
313
+ settle(err instanceof Error ? err : new Error(String(err)));
314
+ }
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Poll for the local port to accept connections, then declare the tunnel ready.
320
+ */
321
+ function waitForPort(entry: TunnelEntry, port: number, settle: (err?: Error) => void): void {
322
+ let attempts = 0;
323
+ const maxAttempts = 15; // 15 * 1000ms = 15s max (SSH + password can take a few seconds)
324
+ const intervalMs = 1000;
325
+
326
+ const timer = setInterval(async () => {
327
+ attempts++;
328
+ if (entry.intentionalStop || !entry.process) {
329
+ clearInterval(timer);
330
+ return;
331
+ }
332
+
333
+ const ok = await probePort(port);
334
+ if (ok) {
335
+ clearInterval(timer);
336
+ entry.running = true;
337
+ entry.reconnecting = false;
338
+ entry.reconnectAttempts = 0;
339
+ console.log(`[SSH Tunnel] Tunnel ready (port ${port} open) for agent "${entry.agentId}"`);
340
+ settle();
341
+ } else if (attempts >= maxAttempts) {
342
+ clearInterval(timer);
343
+ // Declare ready anyway — the port might only open on first inbound connection
344
+ entry.running = true;
345
+ entry.reconnecting = false;
346
+ entry.reconnectAttempts = 0;
347
+ console.log(`[SSH Tunnel] Tunnel assumed ready (port probe timed out) for agent "${entry.agentId}"`);
348
+ settle();
349
+ }
350
+ }, intervalMs);
351
+ }
352
+
353
+ /**
354
+ * Schedule an automatic reconnect with exponential backoff.
355
+ */
356
+ function scheduleReconnect(entry: TunnelEntry): void {
357
+ if (entry.reconnectTimer || entry.intentionalStop) return;
358
+
359
+ entry.reconnecting = true;
360
+ entry.reconnectAttempts++;
361
+
362
+ const delay = Math.min(
363
+ BASE_RECONNECT_DELAY_MS * Math.pow(2, entry.reconnectAttempts - 1),
364
+ MAX_RECONNECT_DELAY_MS
365
+ );
366
+
367
+ console.log(`[SSH Tunnel] Scheduling reconnect for agent "${entry.agentId}" in ${delay}ms (attempt ${entry.reconnectAttempts})`);
368
+
369
+ entry.reconnectTimer = setTimeout(async () => {
370
+ entry.reconnectTimer = null;
371
+ if (entry.intentionalStop) return;
372
+
373
+ try {
374
+ await spawnTunnel(entry);
375
+ console.log(`[SSH Tunnel] Reconnected tunnel for agent "${entry.agentId}"`);
376
+ } catch (err) {
377
+ console.error(`[SSH Tunnel] Reconnect failed for agent "${entry.agentId}":`,
378
+ err instanceof Error ? err.message : err);
379
+ // Schedule another attempt
380
+ if (!entry.intentionalStop) {
381
+ scheduleReconnect(entry);
382
+ }
383
+ }
384
+ }, delay);
385
+ }