u-foo 2.3.26 → 2.3.27

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.3.26",
3
+ "version": "2.3.27",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -7,6 +7,10 @@ const { writeActivityState } = require("./activityStateWriter");
7
7
  * Encapsulates the "write to disk + broadcast event" pattern used by
8
8
  * ptyRunner, launcher, notifier, and internalRunner.
9
9
  *
10
+ * Dedupe key is `state|detail` so that within the same canonical state
11
+ * (e.g. `working`) callers can publish detail transitions like
12
+ * `thinking` → `tool bash` and have them propagate to the dashboard.
13
+ *
10
14
  * @param {object} options
11
15
  * @param {string} options.agentsFile - Path to all-agents.json
12
16
  * @param {string} options.subscriber - Subscriber ID (e.g. "claude-code:abc123")
@@ -22,16 +26,23 @@ function createActivityStatePublisher(options = {}) {
22
26
  } = options;
23
27
 
24
28
  let lastState = "";
29
+ let lastDetail = "";
25
30
 
26
31
  function publish(state, extra = {}, publishOptions = {}) {
27
- if (state === lastState) return false;
32
+ const detail = typeof extra.detail === "string" ? extra.detail : "";
33
+ if (state === lastState && detail === lastDetail) return false;
28
34
  const since = extra.since || undefined;
29
35
  const effectiveForce = typeof publishOptions.force === "boolean"
30
36
  ? publishOptions.force
31
37
  : force;
32
- const changed = writeActivityState(agentsFile, subscriber, state, { since, force: effectiveForce });
38
+ const changed = writeActivityState(agentsFile, subscriber, state, {
39
+ since,
40
+ force: effectiveForce,
41
+ detail,
42
+ });
33
43
  if (!changed) return false;
34
44
  lastState = state;
45
+ lastDetail = detail;
35
46
  // Write to bus events directory for daemon bridge to pick up.
36
47
  // Writes directly to events dir to avoid queueing into subscriber pending files.
37
48
  try {
@@ -51,7 +62,7 @@ function createActivityStatePublisher(options = {}) {
51
62
  subscriber,
52
63
  state,
53
64
  previous: extra.previous || "",
54
- ...extra.detail ? { detail: extra.detail } : {},
65
+ ...detail ? { detail } : {},
55
66
  },
56
67
  };
57
68
  fs.appendFileSync(eventFile, JSON.stringify(entry) + "\n");
@@ -65,7 +76,11 @@ function createActivityStatePublisher(options = {}) {
65
76
  return lastState;
66
77
  }
67
78
 
68
- return { publish, getLastState };
79
+ function getLastDetail() {
80
+ return lastDetail;
81
+ }
82
+
83
+ return { publish, getLastState, getLastDetail };
69
84
  }
70
85
 
71
86
  module.exports = { createActivityStatePublisher };
@@ -4,14 +4,18 @@ const { appendAgentRegistryDiagnostic } = require("../ufoo/agentRegistryDiagnost
4
4
 
5
5
  /**
6
6
  * Centralized helper for writing activity_state to all-agents.json.
7
- * Used by both ptyRunner and notifier to avoid duplicated read-modify-write logic.
8
7
  *
9
- * - Only writes when state actually changes (monotonic activity_since).
8
+ * - Writes when `state` OR `detail` changes (state change refreshes `activity_since`,
9
+ * detail-only change keeps the existing `activity_since` so the "busy duration"
10
+ * shown in dashboards stays continuous across e.g. `working · thinking` →
11
+ * `working · tool bash`).
10
12
  * - Respects priority: won't overwrite working/waiting_input/blocked with idle
11
- * unless explicitly requested via `force` option.
13
+ * unless explicitly requested via `force` option. Detail-only updates within
14
+ * the same state are always allowed.
12
15
  */
13
16
  function writeActivityState(agentsFilePath, subscriber, state, options = {}) {
14
17
  const { since, force = false } = options;
18
+ const detail = typeof options.detail === "string" ? options.detail : "";
15
19
  try {
16
20
  if (!agentsFilePath || !fs.existsSync(agentsFilePath)) return false;
17
21
  const data = readJSON(agentsFilePath, null);
@@ -26,21 +30,33 @@ function writeActivityState(agentsFilePath, subscriber, state, options = {}) {
26
30
  return false;
27
31
  }
28
32
 
29
- const current = data.agents[subscriber].activity_state;
33
+ const agent = data.agents[subscriber];
34
+ const currentState = agent.activity_state;
35
+ const currentDetail = typeof agent.activity_detail === "string" ? agent.activity_detail : "";
30
36
 
31
- // Skip if state unchanged (monotonic update)
32
- if (current === state) return false;
37
+ if (currentState === state && currentDetail === detail) return false;
33
38
 
34
- // Don't overwrite higher-priority states with lower-priority states
35
- // unless force is set (e.g. explicit markIdle from ptyRunner/launcher)
36
- if (!force && (current === "working" || current === "waiting_input" || current === "blocked")) {
39
+ if (
40
+ currentState !== state
41
+ && !force
42
+ && (currentState === "working" || currentState === "waiting_input" || currentState === "blocked")
43
+ ) {
37
44
  if (state === "idle" || state === "ready") return false;
38
45
  }
39
46
 
40
- data.agents[subscriber].activity_state = state;
41
- data.agents[subscriber].activity_since = since
42
- ? new Date(since).toISOString()
43
- : new Date().toISOString();
47
+ agent.activity_state = state;
48
+ if (detail) {
49
+ agent.activity_detail = detail;
50
+ } else {
51
+ delete agent.activity_detail;
52
+ }
53
+ if (currentState !== state) {
54
+ agent.activity_since = since
55
+ ? new Date(since).toISOString()
56
+ : new Date().toISOString();
57
+ } else if (!agent.activity_since) {
58
+ agent.activity_since = new Date().toISOString();
59
+ }
44
60
  writeJSON(agentsFilePath, data);
45
61
  return true;
46
62
  } catch {
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Generic agent activity tracker.
5
+ *
6
+ * Maps high-level lifecycle hooks and normalized provider stream events to the
7
+ * canonical activity model (`starting`/`ready`/`working`/`idle`/`waiting_input`/`blocked`)
8
+ * plus a short detail string (e.g. `thinking`, `tool bash`). Publishes through
9
+ * an injected publisher so the same tracker can be reused by internal,
10
+ * internal-pty, or future runner shapes.
11
+ *
12
+ * Design contract:
13
+ * - The tracker never reads PTY text or guesses state from prose. All state
14
+ * transitions come from explicit hook calls (`notify*`, `request*`,
15
+ * `mark*`) or from normalized provider events.
16
+ * - Detail is best-effort: callers can override it on `notifyTurnStart` /
17
+ * `markIdle` if they want a custom phrasing.
18
+ * - The tracker stays passive about `waiting_input` and `blocked`. They are
19
+ * intentionally exposed as explicit methods (`requestUserInput`,
20
+ * `markBlocked`) and have no auto-trigger from the provider stream — those
21
+ * states should only fire from structured runtime signals.
22
+ */
23
+ function createActivityTracker({ publisher } = {}) {
24
+ if (!publisher || typeof publisher.publish !== "function") {
25
+ throw new Error("createActivityTracker requires a publisher with publish()");
26
+ }
27
+
28
+ let currentState = "";
29
+ let currentDetail = "";
30
+ let currentTurnId = "";
31
+
32
+ function emit(state, detail = "", publishOptions = {}) {
33
+ const normalizedDetail = String(detail || "");
34
+ if (state === currentState && normalizedDetail === currentDetail) return false;
35
+ const previous = currentState;
36
+ const ok = publisher.publish(state, {
37
+ detail: normalizedDetail,
38
+ previous,
39
+ }, publishOptions);
40
+ if (!ok) return false;
41
+ currentState = state;
42
+ currentDetail = normalizedDetail;
43
+ return true;
44
+ }
45
+
46
+ function compactToolName(name) {
47
+ const trimmed = String(name || "").trim();
48
+ if (!trimmed) return "tool";
49
+ if (trimmed.length <= 32) return trimmed;
50
+ return `${trimmed.slice(0, 29)}...`;
51
+ }
52
+
53
+ function notifyStarting(detail = "runner") {
54
+ emit("starting", detail);
55
+ }
56
+
57
+ function notifyReady(detail = "") {
58
+ emit("ready", detail);
59
+ }
60
+
61
+ function notifyTurnStart(detail = "thinking") {
62
+ emit("working", detail);
63
+ }
64
+
65
+ function markIdle(detail = "") {
66
+ emit("idle", detail, { force: true });
67
+ }
68
+
69
+ function requestUserInput(reason = "") {
70
+ emit("waiting_input", reason);
71
+ }
72
+
73
+ function clearUserInput(detail = "") {
74
+ if (currentState !== "waiting_input") return;
75
+ emit("idle", detail, { force: true });
76
+ }
77
+
78
+ function markBlocked(reason = "") {
79
+ emit("blocked", reason);
80
+ }
81
+
82
+ function onProviderEvent(event = {}) {
83
+ const type = event && typeof event === "object" ? String(event.type || "") : "";
84
+ if (!type) return;
85
+
86
+ if (type === "thread_started") {
87
+ // Provider acknowledged the thread; wait for the first turn to flip to working.
88
+ return;
89
+ }
90
+
91
+ if (type === "turn_started") {
92
+ currentTurnId = String(event.turnId || event.turn_id || "");
93
+ emit("working", "thinking");
94
+ return;
95
+ }
96
+
97
+ if (type === "text_delta") {
98
+ // First text delta on a turn that didn't emit turn_started still counts as working.
99
+ if (currentState !== "working") {
100
+ emit("working", "thinking");
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (type === "tool_call") {
106
+ emit("working", `tool ${compactToolName(event.name)}`);
107
+ return;
108
+ }
109
+
110
+ if (type === "tool_result") {
111
+ // Keep `tool <name>` until the next event (text_delta or another tool_call)
112
+ // shifts the detail. This avoids a flap back to `thinking` for a single frame.
113
+ return;
114
+ }
115
+
116
+ if (type === "turn_completed") {
117
+ currentTurnId = "";
118
+ emit("idle", "", { force: true });
119
+ return;
120
+ }
121
+
122
+ if (type === "turn_failed") {
123
+ currentTurnId = "";
124
+ // Default policy: drop back to idle. Callers that want `blocked` semantics
125
+ // for unrecoverable failures should call markBlocked() explicitly.
126
+ emit("idle", "", { force: true });
127
+ }
128
+ }
129
+
130
+ function getState() {
131
+ return { state: currentState, detail: currentDetail, turnId: currentTurnId };
132
+ }
133
+
134
+ return {
135
+ notifyStarting,
136
+ notifyReady,
137
+ notifyTurnStart,
138
+ markIdle,
139
+ requestUserInput,
140
+ clearUserInput,
141
+ markBlocked,
142
+ onProviderEvent,
143
+ getState,
144
+ };
145
+ }
146
+
147
+ module.exports = { createActivityTracker };
@@ -5,6 +5,7 @@ const { spawnSync } = require("child_process");
5
5
  const EventBus = require("../bus");
6
6
  const { readJSON, writeJSON } = require("../bus/utils");
7
7
  const { createActivityStatePublisher } = require("./activityStatePublisher");
8
+ const { createActivityTracker } = require("./activityTracker");
8
9
  const { loadConfig, normalizeCodexInternalThreadMode } = require("../config");
9
10
  const { createCodexThreadProvider } = require("./codexThreadProvider");
10
11
  const { createClaudeThreadProvider } = require("./claudeThreadProvider");
@@ -305,7 +306,8 @@ async function handleEvent(
305
306
  busSender,
306
307
  extraArgs = [],
307
308
  threadRuntime = null,
308
- bootstrapText = ""
309
+ bootstrapText = "",
310
+ tracker = null
309
311
  ) {
310
312
  if (!evt || !evt.data || !evt.data.message) return;
311
313
  const memoryPrefix = buildMemoryPrefix(projectRoot);
@@ -333,6 +335,7 @@ async function handleEvent(
333
335
  emitStreamDelta,
334
336
  streamToPublisher,
335
337
  threadRuntime,
338
+ tracker,
336
339
  });
337
340
  return;
338
341
  }
@@ -379,14 +382,21 @@ async function handleThreadedEvent({
379
382
  emitStreamDelta,
380
383
  streamToPublisher = true,
381
384
  threadRuntime,
385
+ tracker = null,
382
386
  }) {
383
387
  try {
384
388
  const plainReplyParts = [];
389
+ if (tracker && typeof tracker.notifyTurnStart === "function") {
390
+ tracker.notifyTurnStart();
391
+ }
385
392
  for await (const event of threadRuntime.thread.runStreamed(prompt, {})) {
386
393
  if (!event || typeof event !== "object") continue;
387
394
  if (typeof threadRuntime.syncProviderSessionId === "function") {
388
395
  threadRuntime.syncProviderSessionId();
389
396
  }
397
+ if (tracker && typeof tracker.onProviderEvent === "function") {
398
+ tracker.onProviderEvent(event);
399
+ }
390
400
  if (event.type === "text_delta" && event.delta) {
391
401
  if (streamToPublisher) {
392
402
  emitStreamDelta(event.delta);
@@ -420,6 +430,9 @@ async function handleThreadedEvent({
420
430
  if (threadRuntime && typeof threadRuntime.rebuildThread === "function") {
421
431
  await threadRuntime.rebuildThread();
422
432
  }
433
+ if (tracker && typeof tracker.markIdle === "function") {
434
+ tracker.markIdle();
435
+ }
423
436
  const errorText = `[internal:${agentType}] error: ${err && err.message ? err.message : "unknown error"}`;
424
437
  // eslint-disable-next-line no-console
425
438
  console.error(errorText);
@@ -757,10 +770,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
757
770
  const activityPublisher = createActivityStatePublisher({
758
771
  agentsFile, subscriber, projectRoot,
759
772
  });
760
-
761
- function setActivityState(state) {
762
- activityPublisher.publish(state);
763
- }
773
+ const activityTracker = createActivityTracker({ publisher: activityPublisher });
764
774
 
765
775
  function getInteractiveSession(publisher) {
766
776
  const key = String(publisher || "unknown");
@@ -774,7 +784,12 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
774
784
  return session;
775
785
  }
776
786
 
777
- setActivityState("ready");
787
+ activityTracker.notifyStarting("runner");
788
+ if (threadRuntime && threadRuntime.enabled) {
789
+ activityTracker.notifyReady(provider || "");
790
+ } else {
791
+ activityTracker.notifyReady("");
792
+ }
778
793
 
779
794
  // 心跳更新函数
780
795
  const updateHeartbeat = () => {
@@ -835,7 +850,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
835
850
  }
836
851
 
837
852
  if (runnableEvents.length > 0) {
838
- setActivityState("working");
853
+ activityTracker.notifyTurnStart("thinking");
839
854
  }
840
855
 
841
856
  for (const evt of runnableEvents) {
@@ -851,7 +866,8 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
851
866
  busSender,
852
867
  bootstrap.extraArgs,
853
868
  threadRuntime,
854
- bootstrap.promptText
869
+ bootstrap.promptText,
870
+ activityTracker
855
871
  );
856
872
  if (evt.__agentViewRaw) {
857
873
  getInteractiveSession(evt.publisher || "unknown").writeResponsePrompt();
@@ -861,7 +877,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
861
877
  updateHeartbeat();
862
878
  lastHeartbeat = now;
863
879
  if (runnableEvents.length > 0) {
864
- setActivityState("idle");
880
+ activityTracker.markIdle();
865
881
  }
866
882
  await busSender.flush();
867
883
  }
@@ -880,6 +896,7 @@ module.exports = {
880
896
  runInternalRunner,
881
897
  createBusSender,
882
898
  handleEvent,
899
+ handleThreadedEvent,
883
900
  createThreadRuntime,
884
901
  getCodexThreadMode,
885
902
  getWorkerThreadToolMode,
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Detect the launch environment for a ufoo agent.
5
+ *
6
+ * Output is the canonical `mode` plus an optional `terminalApp` hint when the
7
+ * mode is `terminal` (used by the inject layer to pick AppleScript flavor).
8
+ *
9
+ * Detection rules, evaluated top-to-bottom (first match wins):
10
+ *
11
+ * 0. `UFOO_LAUNCH_MODE` is a non-`auto` canonical value → trust it (short-circuit).
12
+ * 1. `TMUX_PANE` is set → tmux.
13
+ * 2. `UFOO_HOST_SESSION_ID` is set → host.
14
+ * 3. `TERM_PROGRAM === "Apple_Terminal"` → terminal / Apple_Terminal.
15
+ * 4. `TERM_PROGRAM === "iTerm.app"` or `ITERM_SESSION_ID` set → terminal / iterm2.
16
+ * 5. Fallback → terminal (no app hint).
17
+ *
18
+ * Host is detected by env var alone here; callers that want to override host
19
+ * with native-terminal evidence (e.g. daemon ops auto-resolution) layer that
20
+ * on top of the detector's output.
21
+ * Internal / internal-pty modes are always set explicitly by the daemon when
22
+ * spawning worker processes (see ptyRunner / daemon ops), so they fall through
23
+ * the explicit short-circuit and never need to be auto-detected here.
24
+ */
25
+
26
+ const CANONICAL_LAUNCH_MODES = Object.freeze([
27
+ "terminal",
28
+ "tmux",
29
+ "host",
30
+ "internal",
31
+ "internal-pty",
32
+ ]);
33
+
34
+ const CANONICAL_LAUNCH_MODE_SET = new Set(CANONICAL_LAUNCH_MODES);
35
+
36
+ function detectLaunchEnvironment(env = process.env) {
37
+ const explicit = String(env.UFOO_LAUNCH_MODE || "").trim();
38
+ if (explicit && explicit !== "auto" && CANONICAL_LAUNCH_MODE_SET.has(explicit)) {
39
+ return { mode: explicit, terminalApp: "", source: "explicit" };
40
+ }
41
+
42
+ if (env.TMUX_PANE) {
43
+ return { mode: "tmux", terminalApp: "", source: "auto" };
44
+ }
45
+
46
+ if (env.UFOO_HOST_SESSION_ID) {
47
+ return { mode: "host", terminalApp: "", source: "auto" };
48
+ }
49
+
50
+ const termProgram = String(env.TERM_PROGRAM || "").trim();
51
+ if (termProgram === "Apple_Terminal") {
52
+ return { mode: "terminal", terminalApp: "Apple_Terminal", source: "auto" };
53
+ }
54
+ if (termProgram === "iTerm.app" || env.ITERM_SESSION_ID) {
55
+ return { mode: "terminal", terminalApp: "iterm2", source: "auto" };
56
+ }
57
+
58
+ return { mode: "terminal", terminalApp: "", source: "auto" };
59
+ }
60
+
61
+ module.exports = {
62
+ detectLaunchEnvironment,
63
+ CANONICAL_LAUNCH_MODES,
64
+ };
@@ -10,6 +10,7 @@ const { showBanner } = require("../utils/banner");
10
10
  const AgentNotifier = require("./notifier");
11
11
  const { ActivityDetector } = require("./activityDetector");
12
12
  const { createActivityStatePublisher } = require("./activityStatePublisher");
13
+ const { detectLaunchEnvironment } = require("./launchEnvironment");
13
14
  const { getUfooPaths } = require("../ufoo/paths");
14
15
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
15
16
  const { probeHostCapabilities } = require("../terminal/adapters/hostAdapter");
@@ -191,18 +192,7 @@ function findPreviousSession(cwd, agentType, tty, tmuxPane) {
191
192
  }
192
193
 
193
194
  function resolveLaunchMode() {
194
- const explicit = process.env.UFOO_LAUNCH_MODE || "";
195
- if (explicit) return explicit;
196
- if (process.env.UFOO_HOST_SESSION_ID) return "host";
197
- // Deprecated: HORIZON_SESSION_ID fallback (remove after migration)
198
- if (process.env.HORIZON_SESSION_ID) {
199
- if (process.env.UFOO_DEBUG) {
200
- console.error("[launcher] HORIZON_SESSION_ID is deprecated, use UFOO_HOST_SESSION_ID");
201
- }
202
- return "host";
203
- }
204
- if (process.env.TMUX_PANE) return "tmux";
205
- return "terminal";
195
+ return detectLaunchEnvironment().mode;
206
196
  }
207
197
 
208
198
  function shouldShowLaunchBanner(agentType = "") {
package/src/chat/index.js CHANGED
@@ -1316,6 +1316,7 @@ async function runChat(projectRoot, options = {}) {
1316
1316
  ...currentMeta,
1317
1317
  activity_state: diskState,
1318
1318
  activity_since: currentMeta.activity_since || diskMeta.activity_since || "",
1319
+ activity_detail: currentMeta.activity_detail || diskMeta.activity_detail || "",
1319
1320
  });
1320
1321
  }
1321
1322
  }
package/src/daemon/ops.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  createSession: createHostSession,
16
16
  } = require("../terminal/adapters/hostAdapter");
17
17
  const { resolveDefaultManualBootstrap } = require("../agent/defaultBootstrap");
18
+ const { detectLaunchEnvironment } = require("../agent/launchEnvironment");
18
19
 
19
20
  function normalizeLaunchAgent(agent = "") {
20
21
  const value = String(agent || "").trim().toLowerCase();
@@ -167,9 +168,19 @@ function resolveConfiguredLaunchMode(configuredMode = "", options = {}) {
167
168
  if (mode === "internal" || mode === "internal-pty" || mode === "tmux" || mode === "terminal" || mode === "host") {
168
169
  return mode;
169
170
  }
170
- if (process.env.TMUX_PANE) return "tmux";
171
- const hostContext = resolveHostLaunchContext(options);
171
+ // Auto mode: defer to the unified detector. Daemon ops differs from the
172
+ // launcher in two ways: host context can also arrive via `options`, and
173
+ // when running under a native terminal app we want to override a stale
174
+ // env-level UFOO_HOST_SESSION_ID. Both are handled here, on top of the
175
+ // detector's output.
176
+ const detected = detectLaunchEnvironment().mode;
177
+ if (detected === "tmux") return "tmux";
172
178
  const nativeTerminalApp = resolveNativeTerminalApp(options);
179
+ if (detected === "host") {
180
+ if (nativeTerminalApp) return "terminal";
181
+ return "host";
182
+ }
183
+ const hostContext = resolveHostLaunchContext(options);
173
184
  if (hostContext.hostDaemonSock && !nativeTerminalApp) return "host";
174
185
  return "terminal";
175
186
  }
@@ -155,6 +155,7 @@ function buildStatus(projectRoot, options = {}) {
155
155
  const tty = meta?.tty || "";
156
156
  const activity_state = meta?.activity_state || "";
157
157
  const activity_since = meta?.activity_since || "";
158
+ const activity_detail = meta?.activity_detail || "";
158
159
  return {
159
160
  id,
160
161
  nickname,
@@ -166,6 +167,7 @@ function buildStatus(projectRoot, options = {}) {
166
167
  tty,
167
168
  activity_state,
168
169
  activity_since,
170
+ activity_detail,
169
171
  host_inject_sock: meta?.host_inject_sock || "",
170
172
  host_daemon_sock: meta?.host_daemon_sock || "",
171
173
  host_name: meta?.host_name || "",