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/README.md CHANGED
@@ -111,6 +111,26 @@ ucode-core run-once --json
111
111
  ucode-core list --json
112
112
  ```
113
113
 
114
+ ## Global Chat (`ufoo -g`)
115
+
116
+ Use `ufoo -g` (or `ufoo --global`) to launch a cross-project chat dashboard. Instead of being scoped to a single project, global mode connects to all running ufoo daemons and lets you switch between projects on the fly.
117
+
118
+ ```bash
119
+ $ ufoo -g
120
+
121
+ > /project list # List all running project daemons
122
+ > /project switch 2 # Switch to project #2
123
+ > /launch claude scope=inplace # Launch agent in current context
124
+ > @claude-1 Start reviewing the auth module
125
+ ```
126
+
127
+ | Command | Description |
128
+ |---------|-------------|
129
+ | `/project list` | List running projects from global runtime registry |
130
+ | `/project switch <index\|path>` | Switch active project daemon connection |
131
+ | `/launch <agent> scope=inplace` | Launch agent in current workspace |
132
+ | `/launch <agent> scope=window` | Launch agent in separate terminal window |
133
+
114
134
  ## Agent Configuration
115
135
 
116
136
  Configure AI providers in `.ufoo/config.json`:
@@ -187,6 +207,7 @@ Bus state lives in `.ufoo/agent/all-agents.json` (metadata), `.ufoo/bus/*` (queu
187
207
  |---------|-------------|
188
208
  | `ufoo` | Launch chat interface (default) |
189
209
  | `ufoo chat` | Launch interactive multi-agent chat UI |
210
+ | `ufoo -g` | Launch global chat mode (cross-project dashboard) |
190
211
  | `ufoo init` | Initialize .ufoo in current project |
191
212
  | `ufoo status` | Show banner, unread bus messages, open decisions |
192
213
  | `ufoo doctor` | Check installation health |
package/README.zh-CN.md CHANGED
@@ -111,6 +111,26 @@ ucode-core run-once --json
111
111
  ucode-core list --json
112
112
  ```
113
113
 
114
+ ## 全局聊天(`ufoo -g`)
115
+
116
+ 使用 `ufoo -g`(或 `ufoo --global`)启动跨项目聊天仪表盘。全局模式会连接所有正在运行的 ufoo 守护进程,支持在不同项目之间快速切换。
117
+
118
+ ```bash
119
+ $ ufoo -g
120
+
121
+ > /project list # 列出所有运行中的项目守护进程
122
+ > /project switch 2 # 切换到第 2 个项目
123
+ > /launch claude scope=inplace # 在当前上下文启动 Agent
124
+ > @claude-1 开始审查 auth 模块
125
+ ```
126
+
127
+ | 命令 | 说明 |
128
+ |------|------|
129
+ | `/project list` | 列出全局运行时注册的项目 |
130
+ | `/project switch <序号\|路径>` | 切换活动项目的 daemon 连接 |
131
+ | `/launch <agent> scope=inplace` | 在当前工作区启动 Agent |
132
+ | `/launch <agent> scope=window` | 在独立终端窗口启动 Agent |
133
+
114
134
  ## Agent 配置
115
135
 
116
136
  在 `.ufoo/config.json` 中配置 AI 提供商:
@@ -187,6 +207,7 @@ Bus 状态存放于 `.ufoo/agent/all-agents.json`(元数据)、`.ufoo/bus/*`
187
207
  |------|------|
188
208
  | `ufoo` | 启动聊天界面(默认) |
189
209
  | `ufoo chat` | 启动交互式多 Agent 聊天 UI |
210
+ | `ufoo -g` | 启动全局聊天模式(跨项目仪表盘) |
190
211
  | `ufoo init` | 在当前项目初始化 .ufoo |
191
212
  | `ufoo status` | 显示 banner、未读消息和未处理决策 |
192
213
  | `ufoo doctor` | 检查安装状态 |
@@ -1,105 +1,7 @@
1
1
  <!-- ufoo -->
2
- ## ufoo Protocol
2
+ ## ufoo Agent Protocol
3
3
 
4
- This project uses **ufoo** for agent coordination. Read the full documentation at `.ufoo/docs/` (symlinked from ufoo installation).
5
-
6
- ### Core Principles
7
-
8
- 1. **Agents are autonomous** - Execute tasks without asking for permission
9
- 2. **Communication via bus** - Use `ufoo bus` for inter-agent messaging
10
- 3. **Decisions are recorded** - Use `ufoo ctx` for decision tracking
11
- 4. **Context is shared** - All agents read from `.ufoo/context/`
12
-
13
- ### Available Commands
14
-
15
- | Command | Description |
16
- |---------|-------------|
17
- | `uinit` | Initialize/repair .ufoo directory |
18
- | `uctx` | Check context status and decisions |
19
- | `ustatus` | Unified status view (banner, unread bus, open decisions) |
20
- | `ubus` | Check bus messages and **auto-execute** them |
21
-
22
- ### Quick Reference
23
-
24
- ```bash
25
- # Context
26
- ufoo ctx decisions -l # List all decisions
27
- ufoo ctx decisions -n 1 # Show latest decision
28
-
29
- # Bus
30
- SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"
31
- [ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)
32
- ufoo bus check $SUBSCRIBER # Check pending messages
33
- ufoo bus send "<id>" "<msg>" # Send message
34
- ufoo bus status # Show bus status
35
-
36
- # Runtime report (shared contract for assistant/ucodex/uclaude)
37
- ufoo report start "<task>" --task <id> --agent "$SUBSCRIBER" --scope public
38
- ufoo report progress "<detail>" --task <id> --agent "$SUBSCRIBER" --scope public
39
- ufoo report done "<summary>" --task <id> --agent "$SUBSCRIBER" --scope public
40
- ufoo report error "<reason>" --task <id> --agent "$SUBSCRIBER" --scope public
41
- ```
42
-
43
- ---
44
-
45
- ## ufoo context Protocol
46
-
47
- On session start, check context status:
48
- ```bash
49
- ufoo ctx decisions -l
50
- ufoo ctx decisions -n 1
51
- ```
52
-
53
- Key files in `.ufoo/context/`:
54
- - `decisions/` - Decision log (append-only)
55
-
56
- **Decision recording policy — "If it has information value, write it down":**
57
-
58
- Record a decision whenever your work produces knowledge that would be useful to your future self, other agents, or the user. The threshold is LOW — when in doubt, record it.
59
-
60
- - **Always record**: architectural choices, trade-off analysis, research findings, non-obvious gotchas, naming/convention changes, external API behavior discovered, performance observations, bug root causes
61
- - **Also record**: open questions you couldn't resolve, assumptions you made, approaches you considered and rejected (with reasons), edge cases noticed but not handled
62
- - **Write the decision BEFORE acting on it** — if your session dies, the knowledge survives
63
- - **Granularity**: A decision can be one sentence ("X doesn't support Y, use Z instead") or a multi-page analysis. Match the depth to the information value.
64
-
65
- ```bash
66
- ufoo ctx decisions new "Short descriptive title"
67
- # Then edit the created file with Context/Decision/Implications
68
- ```
69
-
70
- ---
71
-
72
- ## ufoo bus Protocol
73
-
74
- ### CRITICAL: `ubus` Command Behavior
75
-
76
- **When you receive `ubus`, you MUST:**
77
- 1. Resolve subscriber ID first (reuse existing ID, join only as fallback):
78
- `SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"; [ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)`
79
- 2. Check pending messages: `ufoo bus check $SUBSCRIBER`
80
- 3. **EXECUTE each task immediately** - Do NOT ask the user
81
- 4. Reply to sender: `ufoo bus send "<publisher>" "<result>"`
82
- 5. **CRITICAL: Acknowledge messages after handling**: `ufoo bus ack $SUBSCRIBER`
83
-
84
- **Rules:**
85
- - Execute tasks immediately without asking
86
- - Always reply to the sender
87
- - Do NOT ask "Want me to...?" or "Should I...?"
88
- - Do NOT wait for user confirmation
89
-
90
- ### Message Format
91
-
92
- ```
93
- @you from claude-code:abc123
94
- Type: message/targeted
95
- Content: {"message":"Please analyze the project structure"}
96
- ```
97
-
98
- Extract sender ID from "from" field, use it to reply.
99
-
100
- ### Example
101
-
102
- 1. Receive: `@you from claude-code:bd36dda0 Content: {"message":"Please analyze the project structure"}`
103
- 2. Execute: Analyze the project structure
104
- 3. Reply: `ufoo bus send "claude-code:bd36dda0" "Project contains src/, modules/, bin/"`
4
+ > **Record decisions before acting.** Any knowledge with information value `ufoo ctx decisions new "Title"` BEFORE you act on it.
5
+ > **Auto-execute bus messages.** On `ubus`: execute tasks immediately, reply to sender, then `ufoo bus ack`. Never ask the user.
6
+ > **Full protocol**: `/ufoo` skill (auto-loaded on session start). Docs: `.ufoo/docs/`
105
7
  <!-- /ufoo -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
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",
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Agent Activity State Detector
3
+ * 持续监控 agent 启动后的活动状态(readyDetector 的补充)
4
+ *
5
+ * State machine:
6
+ * STARTING → READY → WORKING ↔ IDLE
7
+ * ↓ ↑
8
+ * WAITING_INPUT --+
9
+ * ↓ ↑
10
+ * BLOCKED ----+
11
+ */
12
+
13
+ const ACTIVITY_STATES = {
14
+ starting: "starting",
15
+ ready: "ready",
16
+ working: "working",
17
+ idle: "idle",
18
+ waiting_input: "waiting_input",
19
+ blocked: "blocked",
20
+ };
21
+
22
+ const DEFAULT_BUFFER_SIZE = 4000;
23
+ const DEFAULT_TAIL_LINES = 10;
24
+ const DEFAULT_BLOCKED_TIMEOUT_MS = 300000;
25
+ const DEFAULT_INTERNAL_QUIET_MS = 3500;
26
+ const DEFAULT_EXTERNAL_QUIET_MS = 5000;
27
+ const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
28
+ const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
29
+
30
+ // Agent-specific patterns that indicate the agent is waiting for user input.
31
+ // Patterns are anchored to prompt/question contexts to reduce false positives.
32
+ const INPUT_PATTERNS = {
33
+ "claude-code": [
34
+ /\bAllow\b.*\bDeny\b/, // Claude Code permission dialog: "Allow | Deny"
35
+ /\ballow mcp\b/i, // MCP tool approval prompt
36
+ /Enter to select.*\u2191\/\u2193 to navigate/, // Ink TUI interactive prompt navigation bar (permissions, AskUserQuestion, Plan approval)
37
+ ],
38
+ codex: [
39
+ /\[Y\/n\]/, // Bracket-style prompt
40
+ /\by\/n\b/i, // y/n prompt (common in confirmation dialogs)
41
+ ],
42
+ };
43
+
44
+ const COMMON_PATTERNS = [
45
+ /Continue\?\s*$/m, // Line-ending "Continue?"
46
+ /Proceed\?\s*$/m, // Line-ending "Proceed?"
47
+ /Press enter/i, // "Press enter to continue"
48
+ /\(y\/n\)\s*:?\s*$/m, // "(y/n)" at line end
49
+ ];
50
+
51
+ // Deny-list for per-line context around a matched prompt.
52
+ // Prevents false positives from code output while avoiding global-buffer suppression.
53
+ const LINE_DENY_CONTEXT_PATTERNS = [
54
+ /function\s+\w+/, // Function definition context
55
+ /\/\//, // Code comment
56
+ /import\s+/, // Import statement
57
+ /require\s*\(/, // Require statement
58
+ ];
59
+
60
+ function toPositiveInt(value, fallback) {
61
+ const parsed = Number.parseInt(String(value || ""), 10);
62
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
63
+ return parsed;
64
+ }
65
+
66
+ function getDefaultQuietWindowMs(mode) {
67
+ const normalized = String(mode || "").trim().toLowerCase();
68
+ if (normalized.includes("internal")) return DEFAULT_INTERNAL_QUIET_MS;
69
+ return DEFAULT_EXTERNAL_QUIET_MS;
70
+ }
71
+
72
+ class ActivityDetector {
73
+ /**
74
+ * @param {string} agentType - e.g. "claude-code", "codex"
75
+ * @param {object} [options]
76
+ * @param {string} [options.mode] - launch mode ("internal-pty"/"terminal"/"tmux"/"iterm2")
77
+ * @param {number} [options.bufferSize=4000] - rolling buffer size in chars
78
+ * @param {number} [options.tailLines=10] - number of tail lines used for quiet-time prompt detection
79
+ * @param {boolean} [options.startOnOutput=false] - allow STARTING -> WORKING on first output
80
+ * @param {number} [options.quietWindowMs] - output quiet window before WAITING_INPUT/IDLE classification
81
+ * @param {number} [options.blockedTimeoutMs=300000] - 5 min WAITING_INPUT → BLOCKED
82
+ */
83
+ constructor(agentType, options = {}) {
84
+ this.agentType = agentType;
85
+ this.mode = String(options.mode || "").trim().toLowerCase();
86
+ this.bufferSize = toPositiveInt(options.bufferSize, DEFAULT_BUFFER_SIZE);
87
+ this.tailLines = toPositiveInt(options.tailLines, DEFAULT_TAIL_LINES);
88
+ this.startOnOutput = options.startOnOutput === true;
89
+ this.blockedTimeoutMs = toPositiveInt(options.blockedTimeoutMs, DEFAULT_BLOCKED_TIMEOUT_MS);
90
+ const optionQuietMs = toPositiveInt(options.quietWindowMs, 0);
91
+ const envQuietMs = toPositiveInt(process.env.UFOO_ACTIVITY_QUIET_MS, 0);
92
+ this.quietWindowMs = optionQuietMs || envQuietMs || getDefaultQuietWindowMs(this.mode);
93
+
94
+ this.state = ACTIVITY_STATES.starting;
95
+ this.since = Date.now();
96
+ this.detail = "";
97
+ this.buffer = "";
98
+ this.callbacks = [];
99
+ this.blockedTimer = null;
100
+ this.quietTimer = null;
101
+ this.quietToken = 0;
102
+ }
103
+
104
+ /**
105
+ * Register a state-change callback: fn(newState, oldState, detail)
106
+ */
107
+ onChange(callback) {
108
+ this.callbacks.push(callback);
109
+ }
110
+
111
+ /**
112
+ * Transition to a new state (internal)
113
+ */
114
+ _setState(newState, detail = "") {
115
+ if (newState === this.state) return;
116
+ const oldState = this.state;
117
+ this.state = newState;
118
+ this.since = Date.now();
119
+ this.detail = detail;
120
+ for (const cb of this.callbacks) {
121
+ try {
122
+ cb(newState, oldState, detail);
123
+ } catch {
124
+ // ignore callback errors
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * STARTING → READY
131
+ */
132
+ markReady() {
133
+ if (this.state !== ACTIVITY_STATES.starting) return;
134
+ this._setState(ACTIVITY_STATES.ready);
135
+ }
136
+
137
+ /**
138
+ * any → WORKING
139
+ * - clears WAITING/BLOCKED timers
140
+ * - cancels pending quiet classification
141
+ * - resets buffer by default when coming from non-WORKING states
142
+ */
143
+ markWorking(options = {}) {
144
+ const hasResetFlag = Object.prototype.hasOwnProperty.call(options, "resetBuffer");
145
+ const resetBuffer = hasResetFlag ? Boolean(options.resetBuffer) : this.state !== ACTIVITY_STATES.working;
146
+ this._clearBlockedTimer();
147
+ this._clearQuietTimer();
148
+ if (resetBuffer) {
149
+ this.buffer = "";
150
+ }
151
+ this._setState(ACTIVITY_STATES.working);
152
+ }
153
+
154
+ /**
155
+ * WORKING/WAITING_INPUT/BLOCKED → IDLE
156
+ * Allows recovery from stuck states when queue drains, marker hits, etc.
157
+ */
158
+ markIdle() {
159
+ if (this.state !== ACTIVITY_STATES.working
160
+ && this.state !== ACTIVITY_STATES.waiting_input
161
+ && this.state !== ACTIVITY_STATES.blocked) return;
162
+ this._clearBlockedTimer();
163
+ this._clearQuietTimer();
164
+ this.buffer = "";
165
+ this._setState(ACTIVITY_STATES.idle);
166
+ }
167
+
168
+ /**
169
+ * Process PTY output.
170
+ * Any output (except STARTING) implies WORKING.
171
+ * WAITING_INPUT/IDLE are only classified after quiet window.
172
+ * @param {string} text - cleaned (ANSI-stripped) output text
173
+ */
174
+ processOutput(text) {
175
+ const normalized = this._normalizeOutput(text);
176
+ if (!normalized) return;
177
+ if (!this._hasMeaningfulOutput(normalized)) return;
178
+ if (this.state === ACTIVITY_STATES.starting) {
179
+ if (!this.startOnOutput) return;
180
+ this._setState(ACTIVITY_STATES.working, "output");
181
+ }
182
+
183
+ this.buffer += normalized;
184
+ // Rolling buffer: keep last N chars
185
+ if (this.buffer.length > this.bufferSize) {
186
+ this.buffer = this.buffer.slice(-this.bufferSize);
187
+ }
188
+
189
+ if (this.state !== ACTIVITY_STATES.working) {
190
+ this._clearBlockedTimer();
191
+ this._setState(ACTIVITY_STATES.working);
192
+ }
193
+ this._scheduleQuietClassification();
194
+ }
195
+
196
+ _normalizeOutput(text) {
197
+ if (!text) return "";
198
+ return String(text)
199
+ .replace(OSC_PATTERN, "")
200
+ .replace(/\r\n/g, "\n")
201
+ .replace(/\r/g, "\n")
202
+ .replace(ANSI_PATTERN, "");
203
+ }
204
+
205
+ _hasMeaningfulOutput(text) {
206
+ if (!text) return false;
207
+ const visible = String(text).replace(/[\s\u0000-\u001F\u007F]+/g, "");
208
+ return visible.length > 0;
209
+ }
210
+
211
+ _scheduleQuietClassification() {
212
+ this.quietToken += 1;
213
+ const quietToken = this.quietToken;
214
+ this._clearQuietTimer();
215
+ this.quietTimer = setTimeout(() => {
216
+ if (quietToken !== this.quietToken) return;
217
+ this.quietTimer = null;
218
+ this._classifyAfterQuietWindow();
219
+ }, this.quietWindowMs);
220
+ if (this.quietTimer && typeof this.quietTimer.unref === "function") {
221
+ this.quietTimer.unref();
222
+ }
223
+ }
224
+
225
+ _classifyAfterQuietWindow() {
226
+ if (this.state !== ACTIVITY_STATES.working) return;
227
+ const tailBuffer = this._tailWindow();
228
+
229
+ // Check agent-specific patterns only after output has stabilized.
230
+ const agentPatterns = INPUT_PATTERNS[this.agentType] || [];
231
+ const allPatterns = [...agentPatterns, ...COMMON_PATTERNS];
232
+ for (const pattern of allPatterns) {
233
+ const match = pattern.exec(tailBuffer);
234
+ if (!match) continue;
235
+ const matchedText = String(match[0] || "");
236
+ const matchIndex = Number.isFinite(match.index)
237
+ ? match.index
238
+ : Math.max(0, tailBuffer.length - matchedText.length);
239
+ if (this._hasDeniedContext(tailBuffer, matchIndex, matchedText.length)) continue;
240
+ this._setState(ACTIVITY_STATES.waiting_input, pattern.source);
241
+ this._startBlockedTimer();
242
+ return;
243
+ }
244
+
245
+ this._setState(ACTIVITY_STATES.idle);
246
+ }
247
+
248
+ _tailWindow() {
249
+ if (!this.buffer) return "";
250
+ const lines = this.buffer.split("\n");
251
+ if (lines.length <= this.tailLines) return this.buffer;
252
+ return lines.slice(-this.tailLines).join("\n");
253
+ }
254
+
255
+ _lineAt(haystack, index) {
256
+ const safeIndex = Math.max(0, Math.min(index, haystack.length));
257
+ const lineStart = haystack.lastIndexOf("\n", safeIndex - 1) + 1;
258
+ const lineEndCandidate = haystack.indexOf("\n", safeIndex);
259
+ const lineEnd = lineEndCandidate >= 0 ? lineEndCandidate : haystack.length;
260
+ return haystack.slice(lineStart, lineEnd);
261
+ }
262
+
263
+ _isInsideCodeFence(haystack, index) {
264
+ const before = haystack.slice(0, Math.max(0, index));
265
+ const fences = before.match(/```/g);
266
+ return (fences ? fences.length : 0) % 2 === 1;
267
+ }
268
+
269
+ _hasDeniedContext(haystack, matchIndex, matchLength = 0) {
270
+ if (this._isInsideCodeFence(haystack, matchIndex)) return true;
271
+ const centerIndex = Math.max(0, matchIndex + Math.max(0, Math.trunc(matchLength / 2)));
272
+ const line = this._lineAt(haystack, centerIndex);
273
+ return LINE_DENY_CONTEXT_PATTERNS.some((deny) => deny.test(line));
274
+ }
275
+
276
+ /**
277
+ * Start the WAITING_INPUT → BLOCKED timer
278
+ */
279
+ _startBlockedTimer() {
280
+ this._clearBlockedTimer();
281
+ this.blockedTimer = setTimeout(() => {
282
+ this.blockedTimer = null;
283
+ if (this.state === ACTIVITY_STATES.waiting_input) {
284
+ this._setState(ACTIVITY_STATES.blocked, `waiting_input for ${this.blockedTimeoutMs}ms`);
285
+ }
286
+ }, this.blockedTimeoutMs);
287
+ // Allow process to exit even if timer is pending
288
+ if (this.blockedTimer && typeof this.blockedTimer.unref === "function") {
289
+ this.blockedTimer.unref();
290
+ }
291
+ }
292
+
293
+ _clearBlockedTimer() {
294
+ if (this.blockedTimer) {
295
+ clearTimeout(this.blockedTimer);
296
+ this.blockedTimer = null;
297
+ }
298
+ }
299
+
300
+ _clearQuietTimer() {
301
+ if (this.quietTimer) {
302
+ clearTimeout(this.quietTimer);
303
+ this.quietTimer = null;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Get current state snapshot
309
+ */
310
+ getState() {
311
+ return {
312
+ state: this.state,
313
+ since: this.since,
314
+ detail: this.detail,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Clean up timers
320
+ */
321
+ destroy() {
322
+ this._clearBlockedTimer();
323
+ this._clearQuietTimer();
324
+ this.callbacks = [];
325
+ }
326
+ }
327
+
328
+ module.exports = { ACTIVITY_STATES, ActivityDetector };
@@ -0,0 +1,67 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { writeActivityState } = require("./activityStateWriter");
4
+
5
+ /**
6
+ * Unified activity state publisher.
7
+ * Encapsulates the "write to disk + broadcast event" pattern used by
8
+ * ptyRunner, launcher, notifier, and internalRunner.
9
+ *
10
+ * @param {object} options
11
+ * @param {string} options.agentsFile - Path to all-agents.json
12
+ * @param {string} options.subscriber - Subscriber ID (e.g. "claude-code:abc123")
13
+ * @param {string} options.projectRoot - Project root (unused, kept for API compat)
14
+ * @param {boolean} [options.force=true] - Force overwrite priority-protected states
15
+ */
16
+ function createActivityStatePublisher(options = {}) {
17
+ const {
18
+ agentsFile,
19
+ subscriber,
20
+ force = true,
21
+ } = options;
22
+
23
+ let lastState = "";
24
+
25
+ function publish(state, extra = {}) {
26
+ if (state === lastState) return false;
27
+ const since = extra.since || undefined;
28
+ const changed = writeActivityState(agentsFile, subscriber, state, { since, force });
29
+ if (!changed) return false;
30
+ lastState = state;
31
+ // Write to bus events directory for daemon bridge to pick up.
32
+ // Writes directly to events dir to avoid queueing into subscriber pending files.
33
+ try {
34
+ const eventsDir = path.join(
35
+ path.dirname(path.dirname(agentsFile)),
36
+ "bus", "events"
37
+ );
38
+ const date = new Date().toISOString().slice(0, 10);
39
+ const eventFile = path.join(eventsDir, `${date}.jsonl`);
40
+ const entry = {
41
+ timestamp: new Date().toISOString(),
42
+ type: "status/agent",
43
+ event: "activity_state_changed",
44
+ publisher: subscriber,
45
+ target: "*",
46
+ data: {
47
+ subscriber,
48
+ state,
49
+ previous: extra.previous || "",
50
+ ...extra.detail ? { detail: extra.detail } : {},
51
+ },
52
+ };
53
+ fs.appendFileSync(eventFile, JSON.stringify(entry) + "\n");
54
+ } catch {
55
+ // ignore event write errors — dashboard polling is the fallback
56
+ }
57
+ return true;
58
+ }
59
+
60
+ function getLastState() {
61
+ return lastState;
62
+ }
63
+
64
+ return { publish, getLastState };
65
+ }
66
+
67
+ module.exports = { createActivityStatePublisher };
@@ -0,0 +1,40 @@
1
+ const fs = require("fs");
2
+
3
+ /**
4
+ * Centralized helper for writing activity_state to all-agents.json.
5
+ * Used by both ptyRunner and notifier to avoid duplicated read-modify-write logic.
6
+ *
7
+ * - Only writes when state actually changes (monotonic activity_since).
8
+ * - Respects priority: won't overwrite working/waiting_input/blocked with idle
9
+ * unless explicitly requested via `force` option.
10
+ */
11
+ function writeActivityState(agentsFilePath, subscriber, state, options = {}) {
12
+ const { since, force = false } = options;
13
+ try {
14
+ if (!agentsFilePath || !fs.existsSync(agentsFilePath)) return false;
15
+ const data = JSON.parse(fs.readFileSync(agentsFilePath, "utf8"));
16
+ if (!data.agents || !data.agents[subscriber]) return false;
17
+
18
+ const current = data.agents[subscriber].activity_state;
19
+
20
+ // Skip if state unchanged (monotonic update)
21
+ if (current === state) return false;
22
+
23
+ // Don't overwrite higher-priority states with lower-priority states
24
+ // unless force is set (e.g. explicit markIdle from ptyRunner/launcher)
25
+ if (!force && (current === "working" || current === "waiting_input" || current === "blocked")) {
26
+ if (state === "idle" || state === "ready") return false;
27
+ }
28
+
29
+ data.agents[subscriber].activity_state = state;
30
+ data.agents[subscriber].activity_since = since
31
+ ? new Date(since).toISOString()
32
+ : new Date().toISOString();
33
+ fs.writeFileSync(agentsFilePath, JSON.stringify(data, null, 2));
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ module.exports = { writeActivityState };
@@ -5,6 +5,7 @@ const { spawnSync } = require("child_process");
5
5
  const EventBus = require("../bus");
6
6
  const { runCliAgent } = require("./cliRunner");
7
7
  const { normalizeCliOutput } = require("./normalizeOutput");
8
+ const { createActivityStatePublisher } = require("./activityStatePublisher");
8
9
 
9
10
  function sleep(ms) {
10
11
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -220,6 +221,16 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
220
221
  process.on("SIGINT", stop);
221
222
 
222
223
  const cliSessionState = { cliSessionId, needsSave: false };
224
+ const agentsFile = getUfooPaths(projectRoot).agentsFile;
225
+ const activityPublisher = createActivityStatePublisher({
226
+ agentsFile, subscriber, projectRoot,
227
+ });
228
+
229
+ function setActivityState(state) {
230
+ activityPublisher.publish(state);
231
+ }
232
+
233
+ setActivityState("ready");
223
234
 
224
235
  // 心跳更新函数
225
236
  const updateHeartbeat = () => {
@@ -248,6 +259,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
248
259
  try {
249
260
  const lines = drainQueue(queueFile);
250
261
  if (lines.length > 0) {
262
+ setActivityState("working");
251
263
  const events = [];
252
264
  for (const line of lines) {
253
265
  try {
@@ -289,6 +301,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
289
301
  // 处理消息后更新心跳
290
302
  updateHeartbeat();
291
303
  lastHeartbeat = now;
304
+ setActivityState("idle");
292
305
  }
293
306
  } finally {
294
307
  processing = false;