u-foo 1.5.0 → 1.6.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.
@@ -378,8 +378,15 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
378
378
  results.push({ action: "launch", ok: false, agent, count, error: err.message });
379
379
  }
380
380
  } else if (op.action === "close") {
381
- const ok = await closeAgent(projectRoot, op.agent_id);
382
- results.push({ action: "close", ok, agent_id: op.agent_id });
381
+ const closeResult = await closeAgent(projectRoot, op.agent_id);
382
+ const normalizedClose = closeResult && typeof closeResult === "object"
383
+ ? closeResult
384
+ : { ok: Boolean(closeResult) };
385
+ results.push({
386
+ action: "close",
387
+ agent_id: op.agent_id,
388
+ ...normalizedClose,
389
+ });
383
390
  } else if (op.action === "rename") {
384
391
  const agentId = op.agent_id || "";
385
392
  const nickname = op.nickname || "";
@@ -943,7 +950,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
943
950
  const closeResult = opsResults.find((r) => r.action === "close");
944
951
  const ok = closeResult ? closeResult.ok !== false : true;
945
952
  const reply = ok
946
- ? `Closed ${agent_id}`
953
+ ? (closeResult && closeResult.already_stopped
954
+ ? `Closed ${agent_id} (already stopped)`
955
+ : `Closed ${agent_id}`)
947
956
  : `Close failed: ${closeResult?.error || "unknown error"}`;
948
957
  socket.write(
949
958
  `${JSON.stringify({
@@ -28,6 +28,7 @@ function createDaemonIpcServer(options = {}) {
28
28
  };
29
29
 
30
30
  let lastActiveJson = "";
31
+ let lastMetaJson = "";
31
32
  const statusSyncInterval = setInterval(() => {
32
33
  if (sockets.size === 0) return;
33
34
  try {
@@ -38,8 +39,12 @@ function createDaemonIpcServer(options = {}) {
38
39
  try {
39
40
  const status = buildStatus(projectRoot);
40
41
  const currentActiveJson = JSON.stringify(status.active);
41
- if (currentActiveJson !== lastActiveJson) {
42
+ const currentMetaJson = JSON.stringify(
43
+ (status.active_meta || []).map((m) => `${m.id}:${m.activity_state || ""}`)
44
+ );
45
+ if (currentActiveJson !== lastActiveJson || currentMetaJson !== lastMetaJson) {
42
46
  lastActiveJson = currentActiveJson;
47
+ lastMetaJson = currentMetaJson;
43
48
  sendToSockets({ type: IPC_RESPONSE_TYPES.STATUS, data: status });
44
49
  log(`status sync: active agents changed to ${status.active.length}`);
45
50
  }
package/src/daemon/ops.js CHANGED
@@ -1,4 +1,4 @@
1
- const { spawn } = require("child_process");
1
+ const { spawn, spawnSync } = require("child_process");
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const { loadConfig } = require("../config");
@@ -827,43 +827,55 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
827
827
  }
828
828
 
829
829
  async function closeAgent(projectRoot, agentId) {
830
- if (process.platform !== "darwin") {
831
- return false;
832
- }
833
830
  const resolvedId = resolveAgentId(projectRoot, agentId);
834
831
  const busPath = getUfooPaths(projectRoot).agentsFile;
835
- let pid = null;
832
+ let pid = 0;
836
833
  let launchMode = "";
837
834
  let tty = "";
838
835
  let terminalApp = "";
836
+ let tmuxPane = "";
837
+ let found = false;
839
838
  try {
840
839
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
841
840
  const entry = bus.agents?.[resolvedId];
842
841
  if (entry) {
843
- if (entry.pid) pid = entry.pid;
842
+ found = true;
843
+ const parsedPid = Number.parseInt(entry.pid, 10);
844
+ pid = Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : 0;
844
845
  launchMode = entry.launch_mode || "";
845
846
  tty = entry.tty || "";
846
847
  terminalApp = entry.terminal_app || "";
848
+ tmuxPane = entry.tmux_pane || "";
847
849
  }
848
850
  } catch {
849
- pid = null;
851
+ found = false;
852
+ }
853
+
854
+ if (!found) {
855
+ return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
850
856
  }
857
+
851
858
  const adapterRouter = createTerminalAdapterRouter();
852
859
  const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId });
853
- const canCloseWindow = adapter.capabilities.supportsWindowClose && tty;
860
+ const canCloseWindow = process.platform === "darwin"
861
+ && Boolean(adapter.capabilities.supportsWindowClose)
862
+ && Boolean(tty);
854
863
 
855
864
  // Close process first for faster state transition in chat.
856
865
  let sentSignal = false;
857
- if (pid) {
866
+ let killErr = null;
867
+ if (pid > 0) {
858
868
  try {
859
869
  process.kill(pid, "SIGTERM");
860
870
  sentSignal = true;
861
- } catch {
871
+ } catch (err) {
872
+ killErr = err || null;
862
873
  sentSignal = false;
863
874
  }
864
875
  }
865
876
 
866
- if (sentSignal || (!pid && canCloseWindow)) {
877
+ const pidGone = pid > 0 && !sentSignal && !isAgentPidAlive(pid);
878
+ if (sentSignal || pid === 0 || pidGone) {
867
879
  markAgentInactive(projectRoot, resolvedId);
868
880
  }
869
881
 
@@ -872,7 +884,29 @@ async function closeAgent(projectRoot, agentId) {
872
884
  void closeTerminalWindowByTty(tty, terminalApp).catch(() => false);
873
885
  }
874
886
 
875
- return sentSignal || (!pid && canCloseWindow);
887
+ // Tmux pane cleanup: kill the pane after sending SIGTERM to the process.
888
+ if (launchMode === "tmux" && tmuxPane) {
889
+ try {
890
+ spawnSync("tmux", ["kill-pane", "-t", tmuxPane], { stdio: "ignore", timeout: 3000 });
891
+ } catch {
892
+ // ignore - pane may already be gone
893
+ }
894
+ }
895
+
896
+ if (sentSignal) {
897
+ return { ok: true, resolved_agent_id: resolvedId };
898
+ }
899
+ if (pid === 0 || pidGone) {
900
+ return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
901
+ }
902
+ const reason = killErr && killErr.message
903
+ ? killErr.message
904
+ : "failed to stop process";
905
+ return {
906
+ ok: false,
907
+ error: reason,
908
+ resolved_agent_id: resolvedId,
909
+ };
876
910
  }
877
911
 
878
912
  module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };
@@ -147,7 +147,9 @@ function buildStatus(projectRoot, options = {}) {
147
147
  const launch_mode = meta?.launch_mode || "unknown";
148
148
  const tmux_pane = meta?.tmux_pane || "";
149
149
  const tty = meta?.tty || "";
150
- return { id, nickname, display, launch_mode, tmux_pane, tty };
150
+ const activity_state = meta?.activity_state || "";
151
+ const activity_since = meta?.activity_since || "";
152
+ return { id, nickname, display, launch_mode, tmux_pane, tty, activity_state, activity_since };
151
153
  });
152
154
 
153
155
  return {
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
  */
@@ -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