u-foo 1.5.0 → 1.7.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 (42) hide show
  1. package/README.md +21 -0
  2. package/README.zh-CN.md +21 -0
  3. package/modules/AGENTS.template.md +4 -102
  4. package/package.json +1 -1
  5. package/src/agent/activityDetector.js +328 -0
  6. package/src/agent/activityStatePublisher.js +67 -0
  7. package/src/agent/activityStateWriter.js +40 -0
  8. package/src/agent/internalRunner.js +13 -0
  9. package/src/agent/launcher.js +110 -7
  10. package/src/agent/notifier.js +73 -4
  11. package/src/agent/ptyRunner.js +81 -34
  12. package/src/agent/ufooAgent.js +192 -6
  13. package/src/bus/activate.js +22 -2
  14. package/src/bus/daemon.js +1 -1
  15. package/src/bus/inject.js +29 -10
  16. package/src/bus/message.js +1 -9
  17. package/src/bus/subscriber.js +34 -0
  18. package/src/bus/utils.js +10 -0
  19. package/src/chat/agentBar.js +21 -3
  20. package/src/chat/agentViewController.js +2 -0
  21. package/src/chat/commandExecutor.js +15 -0
  22. package/src/chat/daemonConnection.js +45 -7
  23. package/src/chat/daemonMessageRouter.js +22 -0
  24. package/src/chat/daemonTransport.js +13 -2
  25. package/src/chat/daemonTransportDefaults.js +1 -0
  26. package/src/chat/dashboardKeyController.js +9 -0
  27. package/src/chat/dashboardView.js +32 -9
  28. package/src/chat/index.js +176 -8
  29. package/src/chat/projectCloseController.js +119 -0
  30. package/src/chat/projectRuntimes.js +55 -0
  31. package/src/chat/statusLineController.js +52 -6
  32. package/src/chat/transport.js +41 -5
  33. package/src/cli.js +14 -0
  34. package/src/config.js +1 -0
  35. package/src/daemon/index.js +63 -5
  36. package/src/daemon/ipcServer.js +6 -1
  37. package/src/daemon/ops.js +189 -14
  38. package/src/daemon/status.js +17 -1
  39. package/src/init/index.js +32 -3
  40. package/src/terminal/adapterRouter.js +13 -1
  41. package/src/terminal/adapters/hostAdapter.js +409 -0
  42. package/src/ufoo/agentsStore.js +44 -0
package/src/init/index.js CHANGED
@@ -180,23 +180,52 @@ class UfooInit {
180
180
 
181
181
  let content = fs.readFileSync(filePath, "utf8");
182
182
  const marker = "<!-- ufoo-template -->";
183
+ const block = `${marker}\n${template}\n${marker}`;
184
+
183
185
  if (content.includes(marker)) {
186
+ // Replace existing marker block in-place
184
187
  const startIdx = content.indexOf(marker);
185
188
  const endIdx = content.indexOf(marker, startIdx + marker.length);
186
189
  if (endIdx !== -1) {
187
190
  content =
188
191
  content.slice(0, startIdx) +
189
- `${marker}\n${template}\n${marker}` +
192
+ block +
190
193
  content.slice(endIdx + marker.length);
191
194
  } else {
192
- content += `\n${marker}\n${template}\n${marker}\n`;
195
+ content =
196
+ content.slice(0, startIdx) + block + content.slice(startIdx + marker.length);
193
197
  }
194
198
  } else {
195
- content += `\n${marker}\n${template}\n${marker}\n`;
199
+ // Insert after first heading line for visibility (not buried at end)
200
+ const headingEnd = this.findFirstHeadingEnd(content);
201
+ if (headingEnd !== -1) {
202
+ content =
203
+ content.slice(0, headingEnd) +
204
+ `\n${block}\n\n` +
205
+ content.slice(headingEnd);
206
+ } else {
207
+ content = `${block}\n\n${content}`;
208
+ }
196
209
  }
197
210
  fs.writeFileSync(filePath, content, "utf8");
198
211
  }
199
212
 
213
+ findFirstHeadingEnd(content) {
214
+ // ATX heading: # ... (allow leading indentation and EOF without trailing newline)
215
+ const atxHeading = content.match(/^(?:[ \t]{0,3})#{1,6}[ \t]*[^\n]*(?:\n|$)/m);
216
+ // Setext heading: text line + underline (=== or ---)
217
+ const setextHeading = content.match(/^[^\n]+\n(?:=+|-+)[ \t]*(?:\n|$)/m);
218
+
219
+ let bestMatch = null;
220
+ if (atxHeading && setextHeading) {
221
+ bestMatch = atxHeading.index <= setextHeading.index ? atxHeading : setextHeading;
222
+ } else {
223
+ bestMatch = atxHeading || setextHeading;
224
+ }
225
+ if (!bestMatch) return -1;
226
+ return bestMatch.index + bestMatch[0].length;
227
+ }
228
+
200
229
  /**
201
230
  * 初始化 context 模块
202
231
  */
@@ -6,6 +6,7 @@ const { createTerminalAdapter } = require("./adapters/terminalAdapter");
6
6
  const { createTmuxAdapter } = require("./adapters/tmuxAdapter");
7
7
  const { createInternalQueueAdapter } = require("./adapters/internalQueueAdapter");
8
8
  const { createInternalPtyAdapter } = require("./adapters/internalPtyAdapter");
9
+ const { createHostAdapter } = require("./adapters/hostAdapter");
9
10
 
10
11
  function createTerminalAdapterRouter(options = {}) {
11
12
  const {
@@ -35,7 +36,7 @@ function createTerminalAdapterRouter(options = {}) {
35
36
  }
36
37
 
37
38
  function getAdapter(params = {}) {
38
- const { launchMode = "", agentId = "" } = params;
39
+ const { launchMode = "", agentId = "", meta = null } = params;
39
40
 
40
41
  if (launchMode === "terminal") {
41
42
  return createTerminalAdapter({
@@ -53,6 +54,17 @@ function createTerminalAdapterRouter(options = {}) {
53
54
  });
54
55
  }
55
56
 
57
+ if (launchMode === "host") {
58
+ return createHostAdapter({
59
+ createAdapter,
60
+ injectSock: meta?.host_inject_sock || meta?.hostInjectSock || "",
61
+ daemonSock: meta?.host_daemon_sock || meta?.hostDaemonSock || "",
62
+ hostName: meta?.host_name || meta?.hostName || "",
63
+ sessionId: meta?.host_session_id || meta?.hostSessionId || "",
64
+ hostCapabilities: meta?.host_capabilities || meta?.hostCapabilities || null,
65
+ });
66
+ }
67
+
56
68
  if (launchMode === "internal-pty") {
57
69
  return createInternalPtyAdapter({
58
70
  sendRaw,
@@ -0,0 +1,409 @@
1
+ const { createTerminalCapabilities } = require("../adapterContract");
2
+ const net = require("net");
3
+
4
+ /**
5
+ * Mapping from host protocol command names to ufoo capability flags.
6
+ * When a host reports supporting a command, the corresponding capability is enabled.
7
+ */
8
+ const COMMAND_TO_CAPABILITY = {
9
+ activate: "supportsActivate",
10
+ snapshot: "supportsSnapshot",
11
+ subscribe: "supportsSubscribeFull",
12
+ subscribe_screen: "supportsSubscribeScreen",
13
+ close_session: "supportsWindowClose",
14
+ notify: "supportsNotifierInjector",
15
+ replay: "supportsReplay",
16
+ };
17
+
18
+ /**
19
+ * Send a JSON request to a Unix socket and return the parsed response.
20
+ * Expects the unified envelope: {v, request_id, ok, result|error}
21
+ */
22
+ function sendToSocket(sockPath, request, options = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ if (!sockPath) {
25
+ reject(new Error("socket path not set"));
26
+ return;
27
+ }
28
+
29
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
30
+ ? options.timeoutMs
31
+ : 5000;
32
+
33
+ const client = net.createConnection(sockPath, () => {
34
+ client.write(JSON.stringify(request) + "\n");
35
+ });
36
+
37
+ let buffer = "";
38
+ const timeout = setTimeout(() => {
39
+ client.destroy();
40
+ reject(new Error("host socket timeout"));
41
+ }, timeoutMs);
42
+
43
+ client.on("data", (data) => {
44
+ buffer += data.toString("utf8");
45
+ const lines = buffer.split("\n");
46
+ buffer = lines.pop() || "";
47
+
48
+ for (const line of lines) {
49
+ if (!line.trim()) continue;
50
+ clearTimeout(timeout);
51
+ try {
52
+ const res = JSON.parse(line);
53
+ client.end();
54
+ if (res.ok) {
55
+ resolve(res.result || {});
56
+ } else {
57
+ const err = new Error(res.error || "host request failed");
58
+ err.errorCode = res.error_code || "";
59
+ reject(err);
60
+ }
61
+ } catch (err) {
62
+ client.end();
63
+ reject(err);
64
+ }
65
+ return;
66
+ }
67
+ });
68
+
69
+ client.on("error", (err) => {
70
+ clearTimeout(timeout);
71
+ reject(err);
72
+ });
73
+
74
+ client.on("close", () => {
75
+ clearTimeout(timeout);
76
+ });
77
+ });
78
+ }
79
+
80
+ function getInjectSock() {
81
+ return process.env.UFOO_HOST_INJECT_SOCK
82
+ || process.env.HORIZON_INJECT_SOCK // deprecated fallback
83
+ || "";
84
+ }
85
+
86
+ function getDaemonSock() {
87
+ return process.env.UFOO_HOST_DAEMON_SOCK || "";
88
+ }
89
+
90
+ function normalizeHostValue(value) {
91
+ return typeof value === "string" ? value.trim() : "";
92
+ }
93
+
94
+ function normalizeHostCapabilities(hostCapabilities) {
95
+ if (!hostCapabilities || typeof hostCapabilities !== "object") {
96
+ return null;
97
+ }
98
+ const normalized = { ...hostCapabilities };
99
+ const commands = Array.isArray(normalized.commands) ? normalized.commands : [];
100
+ const sessionCommands = Array.isArray(normalized.session_commands)
101
+ ? normalized.session_commands
102
+ : [];
103
+ if (commands.length === 0 && sessionCommands.length > 0) {
104
+ normalized.commands = [...sessionCommands];
105
+ } else {
106
+ normalized.commands = [...commands];
107
+ }
108
+ return normalized;
109
+ }
110
+
111
+ function clearDynamicCapabilities(capabilities) {
112
+ for (const flag of Object.values(COMMAND_TO_CAPABILITY)) {
113
+ if (flag in capabilities) {
114
+ capabilities[flag] = false;
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Map host-reported commands array to ufoo capability flags.
121
+ * Mutates the capabilities object in-place.
122
+ */
123
+ function applyHostCapabilities(capabilities, hostCommands) {
124
+ if (!Array.isArray(hostCommands)) return;
125
+ for (const cmd of hostCommands) {
126
+ const flag = COMMAND_TO_CAPABILITY[cmd];
127
+ if (flag && flag in capabilities) {
128
+ capabilities[flag] = true;
129
+ }
130
+ }
131
+ }
132
+
133
+ async function probeHostCapabilities(options = {}) {
134
+ const injectSock = normalizeHostValue(options.injectSock) || getInjectSock();
135
+ const daemonSock = normalizeHostValue(options.daemonSock) || getDaemonSock();
136
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
137
+ ? options.timeoutMs
138
+ : 5000;
139
+
140
+ for (const sock of [injectSock, daemonSock]) {
141
+ if (!sock) continue;
142
+ try {
143
+ // eslint-disable-next-line no-await-in-loop
144
+ const result = await sendToSocket(sock, { type: "capabilities" }, { timeoutMs });
145
+ const normalized = normalizeHostCapabilities(result);
146
+ if (normalized) {
147
+ return normalized;
148
+ }
149
+ } catch {
150
+ // try next socket
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * ufoo Terminal Host adapter — connects to any terminal host's per-session
159
+ * inject socket via the ufoo Terminal Host Protocol.
160
+ *
161
+ * Detects UFOO_HOST_SESSION_ID and UFOO_HOST_INJECT_SOCK env vars.
162
+ * Works with any host that implements the protocol (Horizon, Warp, etc.).
163
+ *
164
+ * After connect(), capabilities are dynamically updated based on what
165
+ * the host reports in its capabilities handshake response.
166
+ */
167
+ function createHostAdapter(options = {}) {
168
+ const {
169
+ createAdapter = () => {},
170
+ injectSock = "",
171
+ daemonSock = "",
172
+ hostName = "",
173
+ sessionId = "",
174
+ hostCapabilities: initialHostCapabilities = null,
175
+ } = options;
176
+
177
+ // Start with base capabilities; additional flags are set after connect()
178
+ const capabilities = createTerminalCapabilities({
179
+ supportsSocketProtocol: true,
180
+ supportsSessionReuse: true,
181
+ });
182
+
183
+ const explicitInjectSock = normalizeHostValue(injectSock);
184
+ const explicitDaemonSock = normalizeHostValue(daemonSock);
185
+ const explicitHostName = normalizeHostValue(hostName);
186
+ const explicitSessionId = normalizeHostValue(sessionId);
187
+ const seededHostCapabilities = normalizeHostCapabilities(initialHostCapabilities);
188
+
189
+ // Cached host capabilities from handshake
190
+ let hostCapabilities = seededHostCapabilities ? { ...seededHostCapabilities } : null;
191
+
192
+ function resolveInjectSock() {
193
+ return explicitInjectSock || getInjectSock();
194
+ }
195
+
196
+ function resolveDaemonSock() {
197
+ return explicitDaemonSock || getDaemonSock();
198
+ }
199
+
200
+ function resetCapabilitiesToSeed() {
201
+ clearDynamicCapabilities(capabilities);
202
+ if (seededHostCapabilities) {
203
+ applyHostCapabilities(capabilities, seededHostCapabilities.commands);
204
+ hostCapabilities = { ...seededHostCapabilities };
205
+ } else {
206
+ hostCapabilities = null;
207
+ }
208
+ }
209
+
210
+ resetCapabilitiesToSeed();
211
+
212
+ return createAdapter({
213
+ capabilities,
214
+ handlers: {
215
+ connect: async () => {
216
+ clearDynamicCapabilities(capabilities);
217
+ const result = await probeHostCapabilities({
218
+ injectSock: resolveInjectSock(),
219
+ daemonSock: resolveDaemonSock(),
220
+ });
221
+ if (result) {
222
+ hostCapabilities = result;
223
+ applyHostCapabilities(capabilities, result.commands);
224
+ return true;
225
+ }
226
+ resetCapabilitiesToSeed();
227
+ return false;
228
+ },
229
+ disconnect: async () => {
230
+ clearDynamicCapabilities(capabilities);
231
+ hostCapabilities = null;
232
+ return true;
233
+ },
234
+ send: (data) => {
235
+ const sock = resolveInjectSock();
236
+ if (!sock) return false;
237
+ sendToSocket(sock, { type: "inject", command: String(data) }).catch(() => {});
238
+ return true;
239
+ },
240
+ sendRaw: (data) => {
241
+ const sock = resolveInjectSock();
242
+ if (!sock) return false;
243
+ sendToSocket(sock, { type: "raw", data: String(data) }).catch(() => {});
244
+ return true;
245
+ },
246
+ resize: (cols, rows) => {
247
+ const sock = resolveInjectSock();
248
+ if (!sock) return false;
249
+ sendToSocket(sock, { type: "resize", cols, rows }).catch(() => {});
250
+ return true;
251
+ },
252
+ snapshot: () => {
253
+ if (!capabilities.supportsSnapshot) return false;
254
+ const sock = resolveInjectSock();
255
+ if (!sock) return false;
256
+ sendToSocket(sock, { type: "snapshot" }).catch(() => {});
257
+ return true;
258
+ },
259
+ subscribe: () => {
260
+ if (!capabilities.supportsSubscribeFull) return false;
261
+ const sock = resolveInjectSock();
262
+ if (!sock) return false;
263
+ sendToSocket(sock, { type: "subscribe" }).catch(() => {});
264
+ return true;
265
+ },
266
+ activate: async () => {
267
+ if (!capabilities.supportsActivate) return false;
268
+ const sock = resolveInjectSock();
269
+ if (!sock) return false;
270
+ try {
271
+ await sendToSocket(sock, { type: "activate" });
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ },
277
+ getState: () => ({
278
+ hostName: explicitHostName || process.env.UFOO_HOST_NAME || "",
279
+ sessionId: explicitSessionId || process.env.UFOO_HOST_SESSION_ID || process.env.HORIZON_SESSION_ID || "",
280
+ injectSock: resolveInjectSock(),
281
+ daemonSock: resolveDaemonSock(),
282
+ hostCapabilities,
283
+ }),
284
+ },
285
+ });
286
+ }
287
+
288
+ // --- Host command helpers (for callers that need async results) ---
289
+
290
+ /**
291
+ * Request a terminal snapshot from the host.
292
+ * @param {string} [sockPath] - Override socket path
293
+ * @returns {Promise<{lines: string[], cols: number, rows: number, cursor?: {x: number, y: number}}>}
294
+ */
295
+ async function requestSnapshot(sockPath) {
296
+ const sock = normalizeHostValue(sockPath) || getInjectSock();
297
+ return sendToSocket(sock, { type: "snapshot" });
298
+ }
299
+
300
+ /**
301
+ * Activate (focus) the host terminal window/tab.
302
+ * @param {string} [sockPath] - Override socket path
303
+ * @returns {Promise<{}>}
304
+ */
305
+ async function requestActivate(sockPath) {
306
+ const sock = normalizeHostValue(sockPath) || getInjectSock();
307
+ return sendToSocket(sock, { type: "activate" });
308
+ }
309
+
310
+ /**
311
+ * Send a notification via the host.
312
+ * @param {string} message - Notification message
313
+ * @param {object} [opts] - Options: { title, urgency }
314
+ * @param {string} [sockPath] - Override socket path
315
+ * @returns {Promise<{}>}
316
+ */
317
+ async function requestNotify(message, opts = {}, sockPath) {
318
+ const sock = normalizeHostValue(sockPath) || getInjectSock();
319
+ return sendToSocket(sock, { type: "notify", message, ...opts });
320
+ }
321
+
322
+ /**
323
+ * Close a terminal session via the inject socket.
324
+ * @param {string} [sockPath] - Override socket path
325
+ * @returns {Promise<{}>}
326
+ */
327
+ async function requestCloseSession(sockPath) {
328
+ const sock = normalizeHostValue(sockPath) || getInjectSock();
329
+ return sendToSocket(sock, { type: "close_session" });
330
+ }
331
+
332
+ // --- Daemon lifecycle management (per-host, not per-session) ---
333
+
334
+ /**
335
+ * Create a new terminal session via the daemon management socket.
336
+ * @param {string} [daemonSock] - Override daemon socket path (defaults to env)
337
+ * @param {object} [opts] - Options: { group_id, source_session_id }
338
+ * @returns {Promise<{session_id: string, inject_sock: string}>}
339
+ */
340
+ async function createSession(daemonSock, opts = {}) {
341
+ const sock = normalizeHostValue(daemonSock) || getDaemonSock();
342
+ const req = { type: "create_session" };
343
+ if (opts.group_id) req.group_id = opts.group_id;
344
+ if (opts.source_session_id) req.source_session_id = opts.source_session_id;
345
+ return sendToSocket(sock, req);
346
+ }
347
+
348
+ /**
349
+ * List all terminal sessions via the daemon management socket.
350
+ * @param {string} [daemonSock] - Override daemon socket path
351
+ * @returns {Promise<{sessions: Array<{session_id: string, inject_sock: string}>}>}
352
+ */
353
+ async function listSessions(daemonSock) {
354
+ const sock = normalizeHostValue(daemonSock) || getDaemonSock();
355
+ return sendToSocket(sock, { type: "list_sessions" });
356
+ }
357
+
358
+ /**
359
+ * Close a terminal session via the daemon management socket.
360
+ * @param {string} sessionId - Session to close
361
+ * @param {string} [daemonSock] - Override daemon socket path
362
+ * @returns {Promise<{}>}
363
+ */
364
+ async function closeSession(sessionId, daemonSock) {
365
+ const sock = normalizeHostValue(daemonSock) || getDaemonSock();
366
+ return sendToSocket(sock, { type: "close_session", session_id: sessionId });
367
+ }
368
+
369
+ /**
370
+ * Query host capabilities via the daemon management socket.
371
+ * @param {string} [daemonSock] - Override daemon socket path
372
+ * @returns {Promise<object>}
373
+ */
374
+ async function queryCapabilities(daemonSock) {
375
+ const sock = normalizeHostValue(daemonSock) || getDaemonSock();
376
+ return sendToSocket(sock, { type: "capabilities" });
377
+ }
378
+
379
+ /**
380
+ * Ping the daemon management socket.
381
+ * @param {string} [daemonSock] - Override daemon socket path
382
+ * @returns {Promise<{pong: true}>}
383
+ */
384
+ async function pingDaemon(daemonSock) {
385
+ const sock = normalizeHostValue(daemonSock) || getDaemonSock();
386
+ return sendToSocket(sock, { type: "ping" });
387
+ }
388
+
389
+ module.exports = {
390
+ createHostAdapter,
391
+ // Host command helpers (async, for direct use)
392
+ requestSnapshot,
393
+ requestActivate,
394
+ requestNotify,
395
+ requestCloseSession,
396
+ // Daemon lifecycle API
397
+ createSession,
398
+ listSessions,
399
+ closeSession,
400
+ queryCapabilities,
401
+ pingDaemon,
402
+ // For testing
403
+ sendToSocket,
404
+ COMMAND_TO_CAPABILITY,
405
+ applyHostCapabilities,
406
+ clearDynamicCapabilities,
407
+ normalizeHostCapabilities,
408
+ probeHostCapabilities,
409
+ };
@@ -94,8 +94,52 @@ function loadAgentsData(filePath) {
94
94
  return normalizeAgentsData(data);
95
95
  }
96
96
 
97
+ function parseTimestampMs(value) {
98
+ const ms = Date.parse(String(value || ""));
99
+ return Number.isFinite(ms) ? ms : Number.NaN;
100
+ }
101
+
102
+ function mergeExternalActivityFields(targetMeta, diskMeta) {
103
+ if (!targetMeta || !diskMeta) return;
104
+
105
+ const diskState = toSafeString(diskMeta.activity_state);
106
+ if (!diskState) return;
107
+
108
+ const memoryState = toSafeString(targetMeta.activity_state);
109
+ const diskSince = toSafeString(diskMeta.activity_since);
110
+ const memorySince = toSafeString(targetMeta.activity_since);
111
+ const diskSinceMs = parseTimestampMs(diskSince);
112
+ const memorySinceMs = parseTimestampMs(memorySince);
113
+
114
+ if (!memoryState) {
115
+ targetMeta.activity_state = diskState;
116
+ if (diskSince) targetMeta.activity_since = diskSince;
117
+ return;
118
+ }
119
+
120
+ const preferDisk = Number.isFinite(diskSinceMs)
121
+ && (!Number.isFinite(memorySinceMs) || diskSinceMs > memorySinceMs);
122
+ if (preferDisk) {
123
+ targetMeta.activity_state = diskState;
124
+ targetMeta.activity_since = diskSince;
125
+ }
126
+ }
127
+
97
128
  function saveAgentsData(filePath, data) {
98
129
  const normalized = normalizeAgentsData(data);
130
+
131
+ // Merge externally-managed fields from disk to avoid daemon in-memory writes
132
+ // overwriting fresher runner/notifier state updates.
133
+ const disk = readJSON(filePath, null);
134
+ if (disk && disk.agents && normalized.agents) {
135
+ for (const [id, diskMeta] of Object.entries(disk.agents)) {
136
+ if (!diskMeta || typeof diskMeta !== "object") continue;
137
+ const targetMeta = normalized.agents[id];
138
+ if (!targetMeta || typeof targetMeta !== "object") continue;
139
+ mergeExternalActivityFields(targetMeta, diskMeta);
140
+ }
141
+ }
142
+
99
143
  writeJSON(filePath, normalized);
100
144
  }
101
145