@rubytech/taskmaster 1.0.94 → 1.0.96

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 (45) hide show
  1. package/dist/agents/taskmaster-tools.js +4 -4
  2. package/dist/agents/tool-policy.js +2 -2
  3. package/dist/agents/tools/contact-lookup-tool.js +45 -0
  4. package/dist/agents/tools/contact-update-tool.js +68 -0
  5. package/dist/agents/tools/memory-tool.js +10 -3
  6. package/dist/build-info.json +3 -3
  7. package/dist/cli/provision-seed.js +2 -2
  8. package/dist/control-ui/assets/index-6WdtDXJj.css +1 -0
  9. package/dist/control-ui/assets/index-lbNnMWBM.js +3508 -0
  10. package/dist/control-ui/assets/index-lbNnMWBM.js.map +1 -0
  11. package/dist/control-ui/index.html +2 -2
  12. package/dist/gateway/chat-sanitize.js +121 -5
  13. package/dist/gateway/media-http.js +120 -0
  14. package/dist/gateway/protocol/schema/logs-chat.js +4 -0
  15. package/dist/gateway/public-chat-api.js +5 -3
  16. package/dist/gateway/server-http.js +3 -0
  17. package/dist/gateway/server-methods/chat.js +12 -5
  18. package/dist/gateway/server-methods/wifi.js +202 -0
  19. package/dist/gateway/server-methods.js +2 -0
  20. package/dist/infra/heartbeat-infra-alert.js +143 -0
  21. package/dist/infra/heartbeat-runner.js +13 -0
  22. package/dist/memory/manager.js +15 -8
  23. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
  24. package/extensions/googlechat/node_modules/.bin/taskmaster +0 -0
  25. package/extensions/line/node_modules/.bin/taskmaster +0 -0
  26. package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
  27. package/extensions/matrix/node_modules/.bin/taskmaster +0 -0
  28. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
  29. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
  30. package/extensions/msteams/node_modules/.bin/taskmaster +0 -0
  31. package/extensions/nostr/node_modules/.bin/taskmaster +0 -0
  32. package/extensions/nostr/node_modules/.bin/tsc +0 -0
  33. package/extensions/nostr/node_modules/.bin/tsserver +0 -0
  34. package/extensions/zalo/node_modules/.bin/taskmaster +0 -0
  35. package/extensions/zalouser/node_modules/.bin/taskmaster +0 -0
  36. package/package.json +64 -54
  37. package/scripts/install.sh +0 -0
  38. package/taskmaster-docs/USER-GUIDE.md +1 -1
  39. package/dist/control-ui/assets/index-B7exVNNa.css +0 -1
  40. package/dist/control-ui/assets/index-DfQL37PU.js +0 -3379
  41. package/dist/control-ui/assets/index-DfQL37PU.js.map +0 -1
  42. package/templates/.DS_Store +0 -0
  43. package/templates/customer/.DS_Store +0 -0
  44. package/templates/customer/agents/.DS_Store +0 -0
  45. package/templates/taskmaster/.gitignore +0 -1
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-DfQL37PU.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-B7exVNNa.css">
9
+ <script type="module" crossorigin src="./assets/index-lbNnMWBM.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-6WdtDXJj.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -119,13 +119,18 @@ export function stripEnvelopeFromMessages(messages) {
119
119
  return changed ? next : messages;
120
120
  }
121
121
  // ---------------------------------------------------------------------------
122
- // Base64 image stripping
122
+ // Base64 image stripping & media URL references
123
123
  // ---------------------------------------------------------------------------
124
- // Images must be stored as physical files on disk and referenced by path
125
- // never as inline base64 in transcripts or chat history responses.
126
- // These functions remove base64 data from image content blocks wherever
127
- // they appear (user, assistant, tool messages).
124
+ // Images are stored as physical files on disk and referenced by path.
125
+ // When sending chat history to the UI, base64 image data is replaced with
126
+ // URL references to the /api/media endpoint. The UI renders these as <img>.
127
+ //
128
+ // Flow:
129
+ // 1. Extract file paths from [media attached: /path (type)] text annotations
130
+ // 2. Remove base64 image blocks from content
131
+ // 3. Add { type: "image", url: "/api/media?path=..." } blocks
128
132
  // ---------------------------------------------------------------------------
133
+ import nodePath from "node:path";
129
134
  function isBase64ImageBlock(block) {
130
135
  if (!block || typeof block !== "object")
131
136
  return false;
@@ -148,6 +153,33 @@ function isBase64ImageBlock(block) {
148
153
  }
149
154
  return false;
150
155
  }
156
+ // Pattern: [media attached: /path (mime/type)] or [media attached 1/2: /path (mime/type) | url]
157
+ const MEDIA_PATH_PATTERN = /\[media attached(?:\s+\d+\/\d+)?:\s*(.+?)\s*\(([^)]+)\)(?:\s*\|[^\]]+)?\]/gi;
158
+ /**
159
+ * Parse [media attached: ...] annotations from text to extract file paths.
160
+ */
161
+ function extractMediaRefs(text) {
162
+ if (!text.includes("[media attached"))
163
+ return [];
164
+ const refs = [];
165
+ let match;
166
+ MEDIA_PATH_PATTERN.lastIndex = 0;
167
+ while ((match = MEDIA_PATH_PATTERN.exec(text)) !== null) {
168
+ const absPath = match[1]?.trim();
169
+ const mimeType = match[2]?.trim();
170
+ if (absPath && mimeType) {
171
+ refs.push({ absPath, mimeType });
172
+ }
173
+ }
174
+ return refs;
175
+ }
176
+ function mediaRefToUrl(ref, workspaceRoot) {
177
+ const relPath = nodePath.relative(workspaceRoot, ref.absPath);
178
+ // Must stay within workspace (no ../ escapes)
179
+ if (relPath.startsWith("..") || nodePath.isAbsolute(relPath))
180
+ return null;
181
+ return `/api/media?path=${encodeURIComponent(relPath)}`;
182
+ }
151
183
  function stripBase64FromContentBlocks(content) {
152
184
  let changed = false;
153
185
  const next = content.map((block) => {
@@ -193,3 +225,87 @@ export function stripBase64ImagesFromMessages(messages) {
193
225
  });
194
226
  return changed ? next : messages;
195
227
  }
228
+ // ---------------------------------------------------------------------------
229
+ // Combined media sanitization for chat display
230
+ // ---------------------------------------------------------------------------
231
+ /**
232
+ * Sanitize media in chat messages for UI display.
233
+ * - Extracts file paths from [media attached: ...] text annotations
234
+ * - Removes base64 image blocks
235
+ * - Creates URL-based image references for the /api/media endpoint
236
+ *
237
+ * Must be called BEFORE stripEnvelopeFromMessages (which strips annotations).
238
+ */
239
+ export function sanitizeMediaForChat(messages, workspaceRoot) {
240
+ if (messages.length === 0 || !workspaceRoot) {
241
+ // No workspace context — fall back to plain base64 stripping
242
+ return stripBase64ImagesFromMessages(messages);
243
+ }
244
+ let changed = false;
245
+ const next = messages.map((message) => {
246
+ const result = sanitizeMessageMedia(message, workspaceRoot);
247
+ if (result !== message)
248
+ changed = true;
249
+ return result;
250
+ });
251
+ return changed ? next : messages;
252
+ }
253
+ function sanitizeMessageMedia(message, workspaceRoot) {
254
+ if (!message || typeof message !== "object")
255
+ return message;
256
+ const entry = message;
257
+ // Collect media refs from text content (works for both string and array content)
258
+ const mediaRefs = extractMediaRefsFromMessage(entry);
259
+ // Build URL-based image blocks from annotations
260
+ const imageBlocks = [];
261
+ for (const ref of mediaRefs) {
262
+ const url = mediaRefToUrl(ref, workspaceRoot);
263
+ if (url) {
264
+ imageBlocks.push({ type: "image", url });
265
+ }
266
+ }
267
+ if (!Array.isArray(entry.content)) {
268
+ // String content — no base64 blocks to strip, just add image blocks if found
269
+ if (imageBlocks.length === 0)
270
+ return message;
271
+ const textContent = typeof entry.content === "string" ? entry.content : "";
272
+ return {
273
+ ...entry,
274
+ content: [{ type: "text", text: textContent }, ...imageBlocks],
275
+ };
276
+ }
277
+ // Array content — remove base64 image blocks, add URL-based ones
278
+ let didChange = false;
279
+ const filtered = entry.content.filter((block) => {
280
+ if (isBase64ImageBlock(block)) {
281
+ didChange = true;
282
+ return false;
283
+ }
284
+ return true;
285
+ });
286
+ if (imageBlocks.length > 0) {
287
+ didChange = true;
288
+ filtered.push(...imageBlocks);
289
+ }
290
+ if (!didChange)
291
+ return message;
292
+ return { ...entry, content: filtered };
293
+ }
294
+ function extractMediaRefsFromMessage(entry) {
295
+ if (typeof entry.content === "string") {
296
+ return extractMediaRefs(entry.content);
297
+ }
298
+ if (Array.isArray(entry.content)) {
299
+ const refs = [];
300
+ for (const block of entry.content) {
301
+ if (!block || typeof block !== "object")
302
+ continue;
303
+ const b = block;
304
+ if (b.type === "text" && typeof b.text === "string") {
305
+ refs.push(...extractMediaRefs(b.text));
306
+ }
307
+ }
308
+ return refs;
309
+ }
310
+ return [];
311
+ }
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Workspace media file endpoint
6
+ // ---------------------------------------------------------------------------
7
+ // Serves image files from the workspace root so that chat history can display
8
+ // inline images by URL instead of embedding base64 data in WebSocket messages.
9
+ //
10
+ // Route: GET /api/media?path=<workspace-relative-path>
11
+ // ---------------------------------------------------------------------------
12
+ const ALLOWED_IMAGE_EXTENSIONS = new Set([
13
+ ".png",
14
+ ".jpg",
15
+ ".jpeg",
16
+ ".gif",
17
+ ".webp",
18
+ ".heic",
19
+ ".heif",
20
+ ".bmp",
21
+ ".tiff",
22
+ ".tif",
23
+ ".pdf",
24
+ ]);
25
+ function contentType(ext) {
26
+ switch (ext) {
27
+ case ".png":
28
+ return "image/png";
29
+ case ".jpg":
30
+ case ".jpeg":
31
+ return "image/jpeg";
32
+ case ".gif":
33
+ return "image/gif";
34
+ case ".webp":
35
+ return "image/webp";
36
+ case ".heic":
37
+ return "image/heic";
38
+ case ".heif":
39
+ return "image/heif";
40
+ case ".bmp":
41
+ return "image/bmp";
42
+ case ".tiff":
43
+ case ".tif":
44
+ return "image/tiff";
45
+ case ".pdf":
46
+ return "application/pdf";
47
+ default:
48
+ return "application/octet-stream";
49
+ }
50
+ }
51
+ function isSafeRelativePath(relPath) {
52
+ if (!relPath)
53
+ return false;
54
+ const normalized = path.posix.normalize(relPath);
55
+ if (normalized.startsWith("../") || normalized === "..")
56
+ return false;
57
+ if (normalized.includes("\0"))
58
+ return false;
59
+ return true;
60
+ }
61
+ export function resolveWorkspaceRoot(config) {
62
+ return resolveAgentWorkspaceRoot(config, "admin");
63
+ }
64
+ export function handleMediaRequest(req, res, opts) {
65
+ const urlObj = new URL(req.url ?? "/", "http://localhost");
66
+ if (urlObj.pathname !== "/api/media")
67
+ return false;
68
+ if (req.method !== "GET" && req.method !== "HEAD") {
69
+ res.statusCode = 405;
70
+ res.setHeader("Allow", "GET, HEAD");
71
+ res.end();
72
+ return true;
73
+ }
74
+ const relPath = urlObj.searchParams.get("path") ?? "";
75
+ if (!relPath || !isSafeRelativePath(relPath)) {
76
+ res.statusCode = 404;
77
+ res.end("Not Found");
78
+ return true;
79
+ }
80
+ const ext = path.extname(relPath).toLowerCase();
81
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
82
+ res.statusCode = 403;
83
+ res.end("Forbidden");
84
+ return true;
85
+ }
86
+ const workspaceRoot = resolveWorkspaceRoot(opts.config);
87
+ const filePath = path.resolve(workspaceRoot, relPath);
88
+ // Boundary check: must stay within workspace
89
+ if (!filePath.startsWith(workspaceRoot + path.sep) && filePath !== workspaceRoot) {
90
+ res.statusCode = 403;
91
+ res.end("Forbidden");
92
+ return true;
93
+ }
94
+ let stat;
95
+ try {
96
+ stat = fs.statSync(filePath);
97
+ }
98
+ catch {
99
+ res.statusCode = 404;
100
+ res.end("Not Found");
101
+ return true;
102
+ }
103
+ if (!stat.isFile()) {
104
+ res.statusCode = 404;
105
+ res.end("Not Found");
106
+ return true;
107
+ }
108
+ res.statusCode = 200;
109
+ res.setHeader("Content-Type", contentType(ext));
110
+ res.setHeader("Content-Length", stat.size);
111
+ res.setHeader("Cache-Control", "private, max-age=86400");
112
+ if (req.method === "HEAD") {
113
+ res.end();
114
+ }
115
+ else {
116
+ const stream = fs.createReadStream(filePath);
117
+ stream.pipe(res);
118
+ }
119
+ return true;
120
+ }
@@ -17,6 +17,10 @@ export const LogsTailResultSchema = Type.Object({
17
17
  export const ChatHistoryParamsSchema = Type.Object({
18
18
  sessionKey: NonEmptyString,
19
19
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10_000 })),
20
+ /** Number of messages to skip from the end (newest) before applying limit.
21
+ * offset=0 (default) returns the most recent `limit` messages.
22
+ * offset=50 with limit=50 returns messages 51–100 from the end. */
23
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
20
24
  /** When set, read from this specific session transcript instead of the current one. */
21
25
  sessionId: Type.Optional(NonEmptyString),
22
26
  /** When true, preserve envelope headers (channel, timestamp, sender metadata) on user messages.
@@ -36,7 +36,8 @@ import { requestOtp, verifyOtp } from "./public-chat/otp.js";
36
36
  import { deliverOtp } from "./public-chat/deliver-otp.js";
37
37
  import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
38
38
  import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
39
- import { stripBase64ImagesFromMessages, stripEnvelopeFromMessages } from "./chat-sanitize.js";
39
+ import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
40
+ import { resolveWorkspaceRoot } from "./media-http.js";
40
41
  import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
41
42
  // ---------------------------------------------------------------------------
42
43
  // Helpers
@@ -597,7 +598,7 @@ async function handleChatHistory(req, res) {
597
598
  sendInvalidRequest(res, "X-Session-Key header required");
598
599
  return;
599
600
  }
600
- const { storePath, entry } = loadSessionEntry(sessionKey);
601
+ const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
601
602
  const sessionId = entry?.sessionId;
602
603
  let rawMessages = [];
603
604
  if (entry && storePath) {
@@ -620,7 +621,8 @@ async function handleChatHistory(req, res) {
620
621
  const limitParam = url.searchParams.get("limit");
621
622
  const requested = limitParam ? Math.min(10_000, Math.max(1, Number(limitParam) || 5000)) : 5000;
622
623
  const messages = rawMessages.length > requested ? rawMessages.slice(-requested) : rawMessages;
623
- const sanitized = stripEnvelopeFromMessages(stripBase64ImagesFromMessages(messages));
624
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
625
+ const sanitized = stripEnvelopeFromMessages(sanitizeMediaForChat(messages, workspaceRoot));
624
626
  sendJson(res, 200, {
625
627
  session_key: sessionKey,
626
628
  messages: sanitized,
@@ -13,6 +13,7 @@ import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalize
13
13
  import { applyHookMappings } from "./hooks-mapping.js";
14
14
  import { handleOpenAiHttpRequest } from "./openai-http.js";
15
15
  import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
16
+ import { handleMediaRequest } from "./media-http.js";
16
17
  import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
17
18
  function sendJson(res, status, body) {
18
19
  res.statusCode = status;
@@ -226,6 +227,8 @@ export function createGatewayHttpServer(opts) {
226
227
  }))
227
228
  return;
228
229
  }
230
+ if (handleMediaRequest(req, res, { config: configSnapshot }))
231
+ return;
229
232
  if (canvasHost) {
230
233
  if (await handleA2uiHttpRequest(req, res))
231
234
  return;
@@ -15,7 +15,8 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
15
15
  import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
16
16
  import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
17
17
  import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from "../session-utils.js";
18
- import { stripBase64ImagesFromMessages, stripEnvelopeFromMessages } from "../chat-sanitize.js";
18
+ import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "../chat-sanitize.js";
19
+ import { resolveWorkspaceRoot } from "../media-http.js";
19
20
  import { formatForLog } from "../ws-log.js";
20
21
  function resolveTranscriptPath(params) {
21
22
  const { sessionId, storePath, sessionFile } = params;
@@ -138,7 +139,7 @@ export const chatHandlers = {
138
139
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`));
139
140
  return;
140
141
  }
141
- const { sessionKey, limit, sessionId: requestedSessionId, preserveEnvelopes, } = params;
142
+ const { sessionKey, limit, offset, sessionId: requestedSessionId, preserveEnvelopes, } = params;
142
143
  const publicError = validatePublicSessionAccess(client?.connect?.role, sessionKey);
143
144
  if (publicError) {
144
145
  respond(false, undefined, publicError);
@@ -184,13 +185,18 @@ export const chatHandlers = {
184
185
  rawMessages.push(...current);
185
186
  }
186
187
  }
188
+ const totalMessages = rawMessages.length;
187
189
  const hardMax = 10_000;
188
190
  const defaultLimit = 5000;
189
191
  const requested = typeof limit === "number" ? limit : defaultLimit;
190
192
  const max = Math.min(hardMax, requested);
191
- const messages = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
192
- const withoutBase64 = stripBase64ImagesFromMessages(messages);
193
- const sanitized = preserveEnvelopes ? withoutBase64 : stripEnvelopeFromMessages(withoutBase64);
193
+ const skip = typeof offset === "number" ? Math.min(offset, totalMessages) : 0;
194
+ const end = totalMessages - skip;
195
+ const start = Math.max(0, end - max);
196
+ const messages = start === 0 && end === totalMessages ? rawMessages : rawMessages.slice(start, end);
197
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
198
+ const withMediaUrls = sanitizeMediaForChat(messages, workspaceRoot);
199
+ const sanitized = preserveEnvelopes ? withMediaUrls : stripEnvelopeFromMessages(withMediaUrls);
194
200
  // Diagnostic: log resolution details so we can trace "lost history" reports.
195
201
  const prevCount = entry?.previousSessions?.length ?? 0;
196
202
  context.logGateway.info(`chat.history: sessionKey=${sessionKey} resolvedSessionId=${sessionId ?? "none"} storePath=${storePath ?? "none"} entryExists=${!!entry} previousSessions=${prevCount} rawMessages=${rawMessages.length} sent=${sanitized.length}`);
@@ -219,6 +225,7 @@ export const chatHandlers = {
219
225
  sessionKey,
220
226
  sessionId,
221
227
  messages: sanitized,
228
+ totalMessages,
222
229
  thinkingLevel,
223
230
  modelProvider,
224
231
  model: modelId,
@@ -0,0 +1,202 @@
1
+ /**
2
+ * RPC handlers for WiFi network management on Linux (Raspberry Pi).
3
+ * Uses nmcli (NetworkManager CLI) which ships with Raspberry Pi OS Bookworm.
4
+ * All methods require operator.admin scope (enforced by the catch-all in server-methods.ts).
5
+ */
6
+ import os from "node:os";
7
+ import { runExec } from "../../process/exec.js";
8
+ import { ErrorCodes, errorShape } from "../protocol/index.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+ function requireLinux(respond) {
13
+ if (os.platform() !== "linux") {
14
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "WiFi management is only available on Linux"));
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+ async function nmcliAvailable() {
20
+ try {
21
+ await runExec("nmcli", ["--version"], { timeoutMs: 3_000 });
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /**
29
+ * Parse nmcli terse output for wifi list.
30
+ * Fields: IN-USE, SSID, SIGNAL, SECURITY
31
+ * Terse format uses `:` as separator; SSIDs containing `:` are escaped by nmcli
32
+ * with `\:`, so we split carefully.
33
+ */
34
+ function parseWifiList(stdout) {
35
+ const seen = new Set();
36
+ const networks = [];
37
+ for (const line of stdout.split("\n")) {
38
+ if (!line.trim())
39
+ continue;
40
+ // nmcli terse output escapes colons in SSIDs as \:
41
+ // Split on unescaped colons by replacing \: with a placeholder first
42
+ const placeholder = "\x00";
43
+ const safe = line.replace(/\\:/g, placeholder);
44
+ const parts = safe.split(":");
45
+ if (parts.length < 4)
46
+ continue;
47
+ const inUse = parts[0].replace(new RegExp(placeholder, "g"), ":").trim() === "*";
48
+ const ssid = parts[1].replace(new RegExp(placeholder, "g"), ":").trim();
49
+ const signal = parseInt(parts[2], 10);
50
+ const security = parts.slice(3).join(":").replace(new RegExp(placeholder, "g"), ":").trim();
51
+ if (!ssid)
52
+ continue; // Skip hidden/empty SSIDs
53
+ if (seen.has(ssid))
54
+ continue; // Deduplicate
55
+ seen.add(ssid);
56
+ networks.push({
57
+ ssid,
58
+ signal: isNaN(signal) ? 0 : signal,
59
+ security: security || "Open",
60
+ active: inUse,
61
+ });
62
+ }
63
+ // Sort by signal strength descending (strongest first)
64
+ networks.sort((a, b) => b.signal - a.signal);
65
+ return networks;
66
+ }
67
+ /**
68
+ * Get the IPv4 address of the active WiFi interface.
69
+ */
70
+ async function getWifiIp() {
71
+ try {
72
+ const { stdout } = await runExec("nmcli", ["-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0"], {
73
+ timeoutMs: 5_000,
74
+ });
75
+ // Output like: IP4.ADDRESS[1]:192.168.1.100/24
76
+ for (const line of stdout.split("\n")) {
77
+ if (line.startsWith("IP4.ADDRESS")) {
78
+ const addr = line.split(":")[1]?.split("/")[0]?.trim();
79
+ if (addr)
80
+ return addr;
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // Fallback: not critical
86
+ }
87
+ return null;
88
+ }
89
+ // ---------------------------------------------------------------------------
90
+ // Handlers
91
+ // ---------------------------------------------------------------------------
92
+ export const wifiHandlers = {
93
+ /**
94
+ * Return current WiFi connection status.
95
+ */
96
+ "wifi.status": async ({ respond, context }) => {
97
+ if (!requireLinux(respond))
98
+ return;
99
+ try {
100
+ if (!(await nmcliAvailable())) {
101
+ respond(true, {
102
+ available: false,
103
+ connected: false,
104
+ ssid: null,
105
+ signal: null,
106
+ ip: null,
107
+ });
108
+ return;
109
+ }
110
+ // Check active WiFi connection
111
+ const { stdout } = await runExec("nmcli", ["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "dev", "wifi", "list"], { timeoutMs: 10_000 });
112
+ const networks = parseWifiList(stdout);
113
+ const active = networks.find((n) => n.active);
114
+ const ip = active ? await getWifiIp() : null;
115
+ respond(true, {
116
+ available: true,
117
+ connected: !!active,
118
+ ssid: active?.ssid ?? null,
119
+ signal: active?.signal ?? null,
120
+ ip,
121
+ });
122
+ }
123
+ catch (err) {
124
+ context.logGateway.warn(`wifi.status failed: ${err instanceof Error ? err.message : String(err)}`);
125
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Failed to check WiFi status"));
126
+ }
127
+ },
128
+ /**
129
+ * Scan for available WiFi networks. Triggers a rescan.
130
+ */
131
+ "wifi.scan": async ({ respond, context }) => {
132
+ if (!requireLinux(respond))
133
+ return;
134
+ try {
135
+ if (!(await nmcliAvailable())) {
136
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "NetworkManager (nmcli) is not installed"));
137
+ return;
138
+ }
139
+ const { stdout } = await runExec("nmcli", ["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "dev", "wifi", "list", "--rescan", "yes"], { timeoutMs: 15_000 });
140
+ const networks = parseWifiList(stdout);
141
+ respond(true, { networks });
142
+ }
143
+ catch (err) {
144
+ context.logGateway.warn(`wifi.scan failed: ${err instanceof Error ? err.message : String(err)}`);
145
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Failed to scan WiFi networks"));
146
+ }
147
+ },
148
+ /**
149
+ * Connect to a WiFi network.
150
+ * Params: { ssid: string, password?: string }
151
+ */
152
+ "wifi.connect": async ({ params, respond, context }) => {
153
+ if (!requireLinux(respond))
154
+ return;
155
+ const ssid = typeof params.ssid === "string" ? params.ssid.trim() : "";
156
+ const password = typeof params.password === "string" ? params.password : "";
157
+ if (!ssid) {
158
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "Missing required parameter: ssid"));
159
+ return;
160
+ }
161
+ try {
162
+ if (!(await nmcliAvailable())) {
163
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "NetworkManager (nmcli) is not installed"));
164
+ return;
165
+ }
166
+ const args = ["dev", "wifi", "connect", ssid];
167
+ if (password) {
168
+ args.push("password", password);
169
+ }
170
+ const { stdout, stderr } = await runExec("nmcli", args, { timeoutMs: 30_000 });
171
+ // nmcli prints "Device 'wlan0' successfully activated..." on success
172
+ const combined = `${stdout}\n${stderr}`;
173
+ const success = combined.includes("successfully activated") || combined.includes("successfully added");
174
+ if (success) {
175
+ const ip = await getWifiIp();
176
+ respond(true, { connected: true, ssid, ip });
177
+ }
178
+ else {
179
+ // nmcli returned 0 but no success message — treat as unexpected
180
+ context.logGateway.warn(`wifi.connect: unexpected output: ${combined.slice(0, 300)}`);
181
+ respond(true, { connected: true, ssid, ip: null });
182
+ }
183
+ }
184
+ catch (err) {
185
+ const errObj = err;
186
+ const detail = errObj.stderr?.trim() || errObj.message || "Connection failed";
187
+ context.logGateway.warn(`wifi.connect failed for "${ssid}": ${detail}`);
188
+ // Surface user-friendly error from nmcli stderr
189
+ let message = "Failed to connect to WiFi network";
190
+ if (detail.includes("Secrets were required")) {
191
+ message = "Incorrect password";
192
+ }
193
+ else if (detail.includes("No network with SSID")) {
194
+ message = "Network not found — try scanning again";
195
+ }
196
+ else if (detail.includes("not found")) {
197
+ message = "WiFi adapter not found";
198
+ }
199
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
200
+ }
201
+ },
202
+ };
@@ -34,6 +34,7 @@ import { webHandlers } from "./server-methods/web.js";
34
34
  import { wizardHandlers } from "./server-methods/wizard.js";
35
35
  import { publicChatHandlers } from "./server-methods/public-chat.js";
36
36
  import { tailscaleHandlers } from "./server-methods/tailscale.js";
37
+ import { wifiHandlers } from "./server-methods/wifi.js";
37
38
  import { workspacesHandlers } from "./server-methods/workspaces.js";
38
39
  const ADMIN_SCOPE = "operator.admin";
39
40
  const READ_SCOPE = "operator.read";
@@ -237,6 +238,7 @@ export const coreGatewayHandlers = {
237
238
  ...workspacesHandlers,
238
239
  ...publicChatHandlers,
239
240
  ...tailscaleHandlers,
241
+ ...wifiHandlers,
240
242
  };
241
243
  export async function handleGatewayRequest(opts) {
242
244
  const { req, respond, client, isWebchatConnect, context } = opts;