u-foo 2.3.25 → 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.25",
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
  }
@@ -349,6 +352,27 @@ async function handleEvent(
349
352
  await busSender.flush();
350
353
  }
351
354
 
355
+ function compactToolDetail(value = "", maxLength = 120) {
356
+ const text = String(value || "").replace(/\s+/g, " ").trim();
357
+ if (!text) return "";
358
+ if (text.length <= maxLength) return text;
359
+ return `${text.slice(0, Math.max(0, maxLength - 3))}...`;
360
+ }
361
+
362
+ function summarizeThreadToolCall(event = {}) {
363
+ const name = String(event.name || event.tool || event.tool_name || "tool").trim() || "tool";
364
+ const args = event.args && typeof event.args === "object" ? event.args : {};
365
+ const detail = args.command
366
+ || args.cmd
367
+ || args.code
368
+ || args.path
369
+ || args.file
370
+ || args.target
371
+ || args.query
372
+ || "";
373
+ return [name, compactToolDetail(detail)].filter(Boolean).join(" · ");
374
+ }
375
+
352
376
  async function handleThreadedEvent({
353
377
  agentType,
354
378
  provider,
@@ -358,20 +382,32 @@ async function handleThreadedEvent({
358
382
  emitStreamDelta,
359
383
  streamToPublisher = true,
360
384
  threadRuntime,
385
+ tracker = null,
361
386
  }) {
362
387
  try {
363
388
  const plainReplyParts = [];
389
+ if (tracker && typeof tracker.notifyTurnStart === "function") {
390
+ tracker.notifyTurnStart();
391
+ }
364
392
  for await (const event of threadRuntime.thread.runStreamed(prompt, {})) {
365
393
  if (!event || typeof event !== "object") continue;
366
394
  if (typeof threadRuntime.syncProviderSessionId === "function") {
367
395
  threadRuntime.syncProviderSessionId();
368
396
  }
397
+ if (tracker && typeof tracker.onProviderEvent === "function") {
398
+ tracker.onProviderEvent(event);
399
+ }
369
400
  if (event.type === "text_delta" && event.delta) {
370
401
  if (streamToPublisher) {
371
402
  emitStreamDelta(event.delta);
372
403
  } else {
373
404
  plainReplyParts.push(String(event.delta));
374
405
  }
406
+ } else if (event.type === "tool_call") {
407
+ const summary = summarizeThreadToolCall(event);
408
+ if (streamToPublisher && summary) {
409
+ emitStreamDelta(`\nTool: ${summary}\n`);
410
+ }
375
411
  } else if (event.type === "turn_failed") {
376
412
  throw new Error(event.error || `thread turn failed for ${agentType}`);
377
413
  }
@@ -394,6 +430,9 @@ async function handleThreadedEvent({
394
430
  if (threadRuntime && typeof threadRuntime.rebuildThread === "function") {
395
431
  await threadRuntime.rebuildThread();
396
432
  }
433
+ if (tracker && typeof tracker.markIdle === "function") {
434
+ tracker.markIdle();
435
+ }
397
436
  const errorText = `[internal:${agentType}] error: ${err && err.message ? err.message : "unknown error"}`;
398
437
  // eslint-disable-next-line no-console
399
438
  console.error(errorText);
@@ -731,10 +770,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
731
770
  const activityPublisher = createActivityStatePublisher({
732
771
  agentsFile, subscriber, projectRoot,
733
772
  });
734
-
735
- function setActivityState(state) {
736
- activityPublisher.publish(state);
737
- }
773
+ const activityTracker = createActivityTracker({ publisher: activityPublisher });
738
774
 
739
775
  function getInteractiveSession(publisher) {
740
776
  const key = String(publisher || "unknown");
@@ -748,7 +784,12 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
748
784
  return session;
749
785
  }
750
786
 
751
- setActivityState("ready");
787
+ activityTracker.notifyStarting("runner");
788
+ if (threadRuntime && threadRuntime.enabled) {
789
+ activityTracker.notifyReady(provider || "");
790
+ } else {
791
+ activityTracker.notifyReady("");
792
+ }
752
793
 
753
794
  // 心跳更新函数
754
795
  const updateHeartbeat = () => {
@@ -809,7 +850,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
809
850
  }
810
851
 
811
852
  if (runnableEvents.length > 0) {
812
- setActivityState("working");
853
+ activityTracker.notifyTurnStart("thinking");
813
854
  }
814
855
 
815
856
  for (const evt of runnableEvents) {
@@ -825,7 +866,8 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
825
866
  busSender,
826
867
  bootstrap.extraArgs,
827
868
  threadRuntime,
828
- bootstrap.promptText
869
+ bootstrap.promptText,
870
+ activityTracker
829
871
  );
830
872
  if (evt.__agentViewRaw) {
831
873
  getInteractiveSession(evt.publisher || "unknown").writeResponsePrompt();
@@ -835,7 +877,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex", extraArgs =
835
877
  updateHeartbeat();
836
878
  lastHeartbeat = now;
837
879
  if (runnableEvents.length > 0) {
838
- setActivityState("idle");
880
+ activityTracker.markIdle();
839
881
  }
840
882
  await busSender.flush();
841
883
  }
@@ -854,6 +896,7 @@ module.exports = {
854
896
  runInternalRunner,
855
897
  createBusSender,
856
898
  handleEvent,
899
+ handleThreadedEvent,
857
900
  createThreadRuntime,
858
901
  getCodexThreadMode,
859
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 = "") {
@@ -3,6 +3,12 @@ const { version: packageVersion } = require("../../package.json");
3
3
 
4
4
  const ANSI_RESET = "\x1b[0m";
5
5
  const CLAUDE_ORANGE = "\x1b[38;2;217;119;87m";
6
+ const BUS_STATUS_INDICATORS = {
7
+ working: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
8
+ starting: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
9
+ waiting_input: ["∙", "∙∙", "∙∙∙", "∙∙", "∙"],
10
+ blocked: ["!"],
11
+ };
6
12
 
7
13
  function createAgentViewController(options = {}) {
8
14
  const {
@@ -11,6 +17,8 @@ function createAgentViewController(options = {}) {
11
17
  processStdout = process.stdout,
12
18
  now = () => Date.now(),
13
19
  setTimeoutFn = setTimeout,
20
+ setIntervalFn = setInterval,
21
+ clearIntervalFn = clearInterval,
14
22
  computeAgentBar = () => ({ bar: "", windowStart: 0 }),
15
23
  agentBarHints = { normal: "", dashboard: "" },
16
24
  maxAgentWindow = 4,
@@ -23,6 +31,7 @@ function createAgentViewController(options = {}) {
23
31
  setAgentListWindowStart = () => {},
24
32
  getAgentLabel = (id) => id,
25
33
  getAgentStates = () => ({}),
34
+ getAgentActivityMeta = () => ({}),
26
35
  getProjectRoot = () => process.cwd(),
27
36
  setDashboardView = () => {},
28
37
  setScreenGrabKeys = (value) => {
@@ -62,6 +71,10 @@ function createAgentViewController(options = {}) {
62
71
  let busStartupAgentId = "";
63
72
  let busStartupLineCount = 0;
64
73
  let busAgentReplyActive = false;
74
+ let busStatusInterval = null;
75
+ let busStatusIndex = 0;
76
+ let busStatusKey = "";
77
+ let busStatusLocalStartedAt = 0;
65
78
  const originalRender = screen.render.bind(screen);
66
79
  let renderFrozen = false;
67
80
 
@@ -202,6 +215,130 @@ function createAgentViewController(options = {}) {
202
215
  return hasAnsi(text) ? fitAnsiText(text, normalizedWidth) : plainLine(text, normalizedWidth);
203
216
  }
204
217
 
218
+ function parseTimeMs(value) {
219
+ if (Number.isFinite(value)) return Number(value);
220
+ const text = String(value || "").trim();
221
+ if (!text) return NaN;
222
+ const parsed = Date.parse(text);
223
+ return Number.isFinite(parsed) ? parsed : NaN;
224
+ }
225
+
226
+ function formatElapsed(ms = 0) {
227
+ const totalSeconds = Math.max(0, Math.floor(Number(ms) / 1000));
228
+ return `${totalSeconds} s`;
229
+ }
230
+
231
+ function normalizeActivityState(value = "") {
232
+ const state = String(value || "").trim().toLowerCase();
233
+ if (state === "waiting") return "waiting_input";
234
+ if (state === "busy" || state === "processing") return "working";
235
+ return state;
236
+ }
237
+
238
+ function getActivityLabel(state = "") {
239
+ if (state === "working") return "working";
240
+ if (state === "waiting_input") return "waiting";
241
+ if (state === "blocked") return "blocked";
242
+ if (state === "starting") return "starting";
243
+ if (state === "idle" || state === "ready") return "ready";
244
+ return state || "ready";
245
+ }
246
+
247
+ function isTimedActivityState(state = "") {
248
+ return state === "working"
249
+ || state === "waiting_input"
250
+ || state === "blocked"
251
+ || state === "starting";
252
+ }
253
+
254
+ function asActivityObject(value) {
255
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
256
+ }
257
+
258
+ function pickActivityDetail(meta = {}) {
259
+ const candidates = [
260
+ meta.activity_detail,
261
+ meta.detail,
262
+ meta.status_text,
263
+ meta.command,
264
+ meta.tool_name,
265
+ meta.tool,
266
+ ];
267
+ return String(candidates.find((item) => String(item || "").trim()) || "").trim();
268
+ }
269
+
270
+ function getViewingAgentActivity() {
271
+ const states = getAgentStates() || {};
272
+ const stateEntry = viewingAgent && states ? states[viewingAgent] : "";
273
+ const stateObject = asActivityObject(stateEntry);
274
+ const meta = {
275
+ ...(stateObject || {}),
276
+ ...(asActivityObject(getAgentActivityMeta(viewingAgent)) || {}),
277
+ };
278
+ const state = normalizeActivityState(meta.activity_state || meta.state || (stateObject ? "" : stateEntry) || "");
279
+ const detail = pickActivityDetail(meta);
280
+ const sinceMs = parseTimeMs(meta.activity_since || meta.since || meta.updated_at || meta.updatedAt);
281
+ return { state: state || "ready", detail, sinceMs };
282
+ }
283
+
284
+ function resolveBusStatus() {
285
+ const activity = getViewingAgentActivity();
286
+ const state = activity.state || "ready";
287
+ const timed = isTimedActivityState(state);
288
+ const key = `${viewingAgent || ""}:${state}:${activity.detail || ""}`;
289
+ if (key !== busStatusKey) {
290
+ busStatusKey = key;
291
+ busStatusIndex = 0;
292
+ busStatusLocalStartedAt = now();
293
+ }
294
+ const startedAt = timed && Number.isFinite(activity.sinceMs)
295
+ ? activity.sinceMs
296
+ : busStatusLocalStartedAt;
297
+ return {
298
+ ...activity,
299
+ state,
300
+ label: getActivityLabel(state),
301
+ timed,
302
+ startedAt,
303
+ };
304
+ }
305
+
306
+ function buildBusStatusLine(width = 80, status = resolveBusStatus()) {
307
+ const normalizedWidth = Math.max(1, width);
308
+ const detail = status.detail ? ` · ${status.detail}` : "";
309
+ if (status.timed) {
310
+ const indicators = BUS_STATUS_INDICATORS[status.state] || BUS_STATUS_INDICATORS.working;
311
+ const indicator = indicators[busStatusIndex % indicators.length] || "";
312
+ const elapsed = formatElapsed(now() - status.startedAt);
313
+ return fitText(`${indicator} ${status.label} · ${elapsed}${detail}`, normalizedWidth);
314
+ }
315
+ if (normalizedWidth < 32) return fitText(`ufoo · ${status.label}`, normalizedWidth);
316
+ if (normalizedWidth < 48) return fitText(`ufoo · ${status.label} · Enter send`, normalizedWidth);
317
+ return fitText(`ufoo · ${status.label} · Enter send · Esc back${detail}`, normalizedWidth);
318
+ }
319
+
320
+ function stopBusStatusTimer() {
321
+ if (!busStatusInterval) return;
322
+ clearIntervalFn(busStatusInterval);
323
+ busStatusInterval = null;
324
+ }
325
+
326
+ function syncBusStatusTimer(status) {
327
+ const shouldTick = currentView === "agent" && agentViewUsesBus && status && status.timed;
328
+ if (!shouldTick) {
329
+ stopBusStatusTimer();
330
+ return;
331
+ }
332
+ if (busStatusInterval) return;
333
+ busStatusInterval = setIntervalFn(() => {
334
+ busStatusIndex += 1;
335
+ renderBusView();
336
+ }, 1000);
337
+ if (busStatusInterval && typeof busStatusInterval.unref === "function") {
338
+ busStatusInterval.unref();
339
+ }
340
+ }
341
+
205
342
  function sliceDisplayCells(text = "", startCell = 0, maxCells = 1) {
206
343
  const targetStart = Math.max(0, startCell);
207
344
  const targetWidth = Math.max(1, maxCells);
@@ -456,12 +593,16 @@ function createAgentViewController(options = {}) {
456
593
  const logContentTop = 1;
457
594
  const logContentBottom = Math.max(logContentTop, inputTop - 1);
458
595
  const logContentHeight = Math.max(1, logContentBottom - logContentTop + 1);
596
+ const status = resolveBusStatus();
597
+ const logRows = Math.max(0, logContentHeight - 1);
598
+ const statusRow = logContentTop + logRows;
459
599
 
460
600
  processStdout.write("\x1b[?25l");
461
- const visibleLines = getWrappedBusLogLines(width).slice(-logContentHeight);
462
- for (let i = 0; i < logContentHeight; i += 1) {
601
+ const visibleLines = getWrappedBusLogLines(width).slice(-logRows);
602
+ for (let i = 0; i < logRows; i += 1) {
463
603
  writeAt(logContentTop + i, logLine(visibleLines[i] || "", width));
464
604
  }
605
+ writeAt(statusRow, logLine(buildBusStatusLine(width, status), width));
465
606
 
466
607
  writeAt(inputTop, horizontalLine(width));
467
608
  const viewport = getBusInputViewport(width);
@@ -471,6 +612,7 @@ function createAgentViewController(options = {}) {
471
612
  renderAgentDashboard();
472
613
  const cursorCol = clamp(3 + viewport.cursorCol, 1, width);
473
614
  processStdout.write(`\x1b[${inputTop + 1};${cursorCol}H\x1b[?25h`);
615
+ syncBusStatusTimer(status);
474
616
  }
475
617
 
476
618
  function renderAgentDashboard() {
@@ -566,6 +708,7 @@ function createAgentViewController(options = {}) {
566
708
  agentViewUsesBus = false;
567
709
  agentOutputSuppressed = false;
568
710
  agentBarVisible = false;
711
+ stopBusStatusTimer();
569
712
  busInputValue = "";
570
713
  busInputCursor = 0;
571
714
  busLogLines = [];
@@ -871,6 +1014,16 @@ function createAgentViewController(options = {}) {
871
1014
  }
872
1015
  }
873
1016
 
1017
+ function refreshAgentView() {
1018
+ if (currentView !== "agent") return false;
1019
+ if (agentViewUsesBus) {
1020
+ renderBusView();
1021
+ } else {
1022
+ renderAgentDashboard();
1023
+ }
1024
+ return true;
1025
+ }
1026
+
874
1027
  function isAgentBarVisible() {
875
1028
  return agentBarVisible;
876
1029
  }
@@ -882,6 +1035,7 @@ function createAgentViewController(options = {}) {
882
1035
  getAgentInputSuppressUntil,
883
1036
  getAgentOutputSuppressed,
884
1037
  setAgentOutputSuppressed,
1038
+ refreshAgentView,
885
1039
  isAgentBarVisible,
886
1040
  renderAgentDashboard,
887
1041
  setAgentBarVisible,
@@ -76,7 +76,7 @@ function createDaemonMessageRouter(options = {}) {
76
76
  const key = typeof data.key === "string" ? data.key : "";
77
77
  if (isLikelySubscriberId(key)) {
78
78
  if (data.phase === BUS_STATUS_PHASES.START) {
79
- setTransientAgentState(key, "working");
79
+ setTransientAgentState(key, "working", { detail: text });
80
80
  } else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
81
81
  clearTransientAgentState(key);
82
82
  }
@@ -354,6 +354,18 @@ function createDaemonMessageRouter(options = {}) {
354
354
  function handleBusMessage(msg) {
355
355
  const data = msg.data || {};
356
356
  if (data.event === "activity_state_changed") {
357
+ const agentId = String(data.subscriber || data.publisher || "").trim();
358
+ const state = String(data.state || data.activity_state || "").trim();
359
+ const detailSource = data.detail || (data.data && data.data.detail) || data.message || "";
360
+ if (agentId && state) {
361
+ const normalized = state.toLowerCase();
362
+ if (normalized === "idle" || normalized === "ready") {
363
+ clearTransientAgentState(agentId);
364
+ } else {
365
+ setTransientAgentState(agentId, state, { detail: detailSource });
366
+ }
367
+ refreshDashboard();
368
+ }
357
369
  requestStatus();
358
370
  return true;
359
371
  }
package/src/chat/index.js CHANGED
@@ -61,6 +61,7 @@ const { loadPromptProfileRegistry } = require("../group/promptProfiles");
61
61
  const {
62
62
  DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
63
63
  setTransientAgentState: setTransientAgentStateValue,
64
+ getTransientAgentStateEntry,
64
65
  getTransientAgentState,
65
66
  pruneTransientAgentStates,
66
67
  } = require("./transientAgentState");
@@ -1315,6 +1316,7 @@ async function runChat(projectRoot, options = {}) {
1315
1316
  ...currentMeta,
1316
1317
  activity_state: diskState,
1317
1318
  activity_since: currentMeta.activity_since || diskMeta.activity_since || "",
1319
+ activity_detail: currentMeta.activity_detail || diskMeta.activity_detail || "",
1318
1320
  });
1319
1321
  }
1320
1322
  }
@@ -1340,7 +1342,11 @@ async function runChat(projectRoot, options = {}) {
1340
1342
  selectedAgentIndex = 0;
1341
1343
  }
1342
1344
  }
1343
- renderAgentDashboard();
1345
+ if (agentViewController && typeof agentViewController.refreshAgentView === "function") {
1346
+ agentViewController.refreshAgentView();
1347
+ } else {
1348
+ renderAgentDashboard();
1349
+ }
1344
1350
  return;
1345
1351
  }
1346
1352
  if (focusMode === "dashboard") {
@@ -1537,13 +1543,39 @@ async function runChat(projectRoot, options = {}) {
1537
1543
  getAgentLabel,
1538
1544
  getAgentStates: () => {
1539
1545
  const states = {};
1540
- if (activeAgentMetaMap) {
1541
- for (const [id, meta] of activeAgentMetaMap) {
1542
- if (meta && meta.activity_state) states[id] = meta.activity_state;
1546
+ for (const id of activeAgents) {
1547
+ let state = "";
1548
+ if (activeAgentMetaMap) {
1549
+ const meta = activeAgentMetaMap.get(id);
1550
+ if (meta && meta.activity_state) state = meta.activity_state;
1551
+ }
1552
+ if (!state) {
1553
+ state = getTransientAgentState(transientAgentStateMap, id, {
1554
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1555
+ });
1543
1556
  }
1557
+ if (state) states[id] = state;
1544
1558
  }
1545
1559
  return states;
1546
1560
  },
1561
+ getAgentActivityMeta: (agentId) => {
1562
+ const id = String(agentId || "").trim();
1563
+ const meta = activeAgentMetaMap && activeAgentMetaMap.get(id)
1564
+ ? { ...activeAgentMetaMap.get(id) }
1565
+ : {};
1566
+ const transient = getTransientAgentStateEntry(transientAgentStateMap, id, {
1567
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1568
+ });
1569
+ if (transient) {
1570
+ const previousState = meta.activity_state;
1571
+ meta.activity_state = transient.state;
1572
+ if ((!meta.activity_since || previousState !== transient.state) && Number.isFinite(transient.updatedAt)) {
1573
+ meta.activity_since = new Date(transient.updatedAt).toISOString();
1574
+ }
1575
+ if (transient.detail) meta.activity_detail = transient.detail;
1576
+ }
1577
+ return meta;
1578
+ },
1547
1579
  getProjectRoot: () => activeProjectRoot,
1548
1580
  setDashboardView: (value) => {
1549
1581
  dashboardView = value;
@@ -1643,9 +1675,9 @@ async function runChat(projectRoot, options = {}) {
1643
1675
  appendStreamDelta,
1644
1676
  finalizeStream,
1645
1677
  hasStream: (publisher) => streamTracker.hasStream(publisher),
1646
- setTransientAgentState: (agentId, state) => {
1678
+ setTransientAgentState: (agentId, state, options) => {
1647
1679
  if (!agentId || !state) return;
1648
- setTransientAgentStateValue(transientAgentStateMap, agentId, state);
1680
+ setTransientAgentStateValue(transientAgentStateMap, agentId, state, options);
1649
1681
  },
1650
1682
  clearTransientAgentState: (agentId) => {
1651
1683
  if (!agentId) return;
@@ -1653,7 +1685,11 @@ async function runChat(projectRoot, options = {}) {
1653
1685
  },
1654
1686
  refreshDashboard: () => {
1655
1687
  if (getCurrentView() === "agent") {
1656
- renderAgentDashboard();
1688
+ if (agentViewController && typeof agentViewController.refreshAgentView === "function") {
1689
+ agentViewController.refreshAgentView();
1690
+ } else {
1691
+ renderAgentDashboard();
1692
+ }
1657
1693
  return;
1658
1694
  }
1659
1695
  renderDashboard();
@@ -6,23 +6,38 @@ function normalizeNow(now) {
6
6
  return Number.isFinite(now) ? now : Date.now();
7
7
  }
8
8
 
9
- function setTransientAgentState(store, agentId, state, now = Date.now()) {
9
+ function normalizeSetOptions(nowOrOptions, detailArg = "") {
10
+ if (nowOrOptions && typeof nowOrOptions === "object") {
11
+ return {
12
+ now: normalizeNow(nowOrOptions.now),
13
+ detail: String(nowOrOptions.detail || "").trim(),
14
+ };
15
+ }
16
+ return {
17
+ now: normalizeNow(nowOrOptions),
18
+ detail: String(detailArg || "").trim(),
19
+ };
20
+ }
21
+
22
+ function setTransientAgentState(store, agentId, state, nowOrOptions = Date.now(), detailArg = "") {
10
23
  if (!(store instanceof Map)) return;
11
24
  const id = String(agentId || "").trim();
12
25
  const nextState = String(state || "").trim();
13
26
  if (!id || !nextState) return;
27
+ const options = normalizeSetOptions(nowOrOptions, detailArg);
14
28
  store.set(id, {
15
29
  state: nextState,
16
- updatedAt: normalizeNow(now),
30
+ updatedAt: options.now,
31
+ detail: options.detail,
17
32
  });
18
33
  }
19
34
 
20
- function getTransientAgentState(store, agentId, options = {}) {
21
- if (!(store instanceof Map)) return "";
35
+ function getTransientAgentStateEntry(store, agentId, options = {}) {
36
+ if (!(store instanceof Map)) return null;
22
37
  const id = String(agentId || "").trim();
23
- if (!id) return "";
38
+ if (!id) return null;
24
39
  const entry = store.get(id);
25
- if (!entry) return "";
40
+ if (!entry) return null;
26
41
 
27
42
  const ttlMs = Number.isFinite(options.ttlMs)
28
43
  ? Math.max(0, Math.trunc(options.ttlMs))
@@ -32,16 +47,23 @@ function getTransientAgentState(store, agentId, options = {}) {
32
47
  const updatedAt = typeof entry === "object" && Number.isFinite(entry.updatedAt)
33
48
  ? entry.updatedAt
34
49
  : now;
50
+ const detail = typeof entry === "object" ? String(entry.detail || "").trim() : "";
35
51
 
36
52
  if (!state) {
37
53
  store.delete(id);
38
- return "";
54
+ return null;
39
55
  }
40
56
  if (ttlMs > 0 && now - updatedAt > ttlMs) {
41
57
  store.delete(id);
42
- return "";
58
+ return null;
43
59
  }
44
- return state;
60
+ return { state, updatedAt, detail };
61
+ }
62
+
63
+ function getTransientAgentState(store, agentId, options = {}) {
64
+ const entry = getTransientAgentStateEntry(store, agentId, options);
65
+ if (!entry) return "";
66
+ return entry.state;
45
67
  }
46
68
 
47
69
  function pruneTransientAgentStates(store, activeAgentIds = [], options = {}) {
@@ -59,6 +81,7 @@ function pruneTransientAgentStates(store, activeAgentIds = [], options = {}) {
59
81
  module.exports = {
60
82
  DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
61
83
  setTransientAgentState,
84
+ getTransientAgentStateEntry,
62
85
  getTransientAgentState,
63
86
  pruneTransientAgentStates,
64
87
  };
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 || "",