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.
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/modules/AGENTS.template.md +4 -102
- package/package.json +1 -1
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/daemonConnection.js +45 -7
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +13 -2
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +9 -0
- package/src/chat/dashboardView.js +32 -9
- package/src/chat/index.js +148 -6
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/transport.js +41 -5
- package/src/daemon/index.js +12 -3
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +46 -12
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
@@ -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;
|