u-foo 3.0.10 → 3.0.12

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 (70) hide show
  1. package/README.md +25 -9
  2. package/README.zh-CN.md +22 -9
  3. package/SKILLS/ubus/SKILL.md +15 -9
  4. package/SKILLS/ufoo/SKILL.md +20 -6
  5. package/dist/tui/darwin-arm64/ufoo-tui +0 -0
  6. package/dist/tui/darwin-x64/ufoo-tui +0 -0
  7. package/dist/tui/linux-arm64/ufoo-tui +0 -0
  8. package/dist/tui/linux-x64/ufoo-tui +0 -0
  9. package/package.json +11 -3
  10. package/scripts/pack-tui.js +112 -0
  11. package/scripts/postinstall.js +11 -0
  12. package/src/agents/activity/activityReconcile.js +106 -0
  13. package/src/agents/activity/activityStatePublisher.js +31 -2
  14. package/src/agents/activity/index.js +1 -0
  15. package/src/agents/launch/launcher.js +19 -0
  16. package/src/agents/launch/ptyRunner.js +20 -1
  17. package/src/agents/prompts/groupBootstrap.js +4 -0
  18. package/src/agents/prompts/native/ufoo.js +2 -0
  19. package/src/app/chat/ChatController.js +433 -0
  20. package/src/app/chat/agentDirectory.js +63 -0
  21. package/src/app/chat/agentEnter.js +70 -0
  22. package/src/app/chat/agentIdentity.js +50 -0
  23. package/src/app/chat/bootstrap.js +66 -0
  24. package/src/app/chat/commandExecutor.js +108 -0
  25. package/src/app/chat/commands.js +31 -0
  26. package/src/app/chat/dashboardView.js +6 -2
  27. package/src/app/chat/historyStore.js +181 -0
  28. package/src/app/chat/index.js +14 -2
  29. package/src/app/chat/inputSubmitHandler.js +21 -7
  30. package/src/app/chat/ipcBuilders.js +52 -0
  31. package/src/app/chat/multiWindow/paneManager.js +10 -1
  32. package/src/app/chat/multiWindow/renderer.js +1 -1
  33. package/src/app/chat/multiWindow/vtFrame.js +93 -0
  34. package/src/app/chat/streamState.js +182 -0
  35. package/src/app/cli/features/doctor.js +22 -0
  36. package/src/code/UCODE_PROMPT.md +2 -0
  37. package/src/code/UcodeController.js +156 -0
  38. package/src/code/context/planGraphService.js +4 -0
  39. package/src/code/repl.js +4 -3
  40. package/src/code/runtime/taskLoop.js +46 -50
  41. package/src/code/tui.js +13 -2
  42. package/src/code/ucodeSlashDispatch.js +241 -0
  43. package/src/coordination/bus/activate.js +3 -0
  44. package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
  45. package/src/runtime/contracts/uiProtocol.js +190 -0
  46. package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
  47. package/src/ui/dashboardBridge.js +81 -0
  48. package/src/ui/format/index.js +2 -2
  49. package/src/ui/index.js +8 -4
  50. package/src/ui/multiPaneBusMirror.js +137 -0
  51. package/src/ui/multiWindowHandoff.js +232 -0
  52. package/src/ui/ptyHandoff.js +23 -0
  53. package/src/ui/rustChatHost.js +1520 -0
  54. package/src/ui/rustMultiSession.js +497 -0
  55. package/src/ui/rustUcodeHost.js +999 -0
  56. package/src/ui/scrollbackReplay.js +82 -0
  57. package/src/ui/settingsBridge.js +49 -0
  58. package/src/ui/toolMergeBridge.js +66 -0
  59. package/src/ui/tuiLauncher.js +105 -0
  60. package/src/ui/ucodeStatusLine.js +74 -0
  61. package/src/ui/uiHostServer.js +339 -0
  62. package/src/ui/MIGRATION.md +0 -334
  63. package/src/ui/ink/ChatApp.js +0 -4163
  64. package/src/ui/ink/DashboardBar.js +0 -691
  65. package/src/ui/ink/InkDemo.js +0 -96
  66. package/src/ui/ink/MultilineInput.js +0 -662
  67. package/src/ui/ink/UcodeApp.js +0 -1675
  68. package/src/ui/ink/agentMirror.js +0 -730
  69. package/src/ui/ink/chatReducer.js +0 -473
  70. package/src/ui/runInk.js +0 -66
package/README.md CHANGED
@@ -113,7 +113,7 @@ per-project MCP server mode.
113
113
 
114
114
  ```text
115
115
  ufoo / ufoo chat
116
- -> src/app/chat + src/ui/ink
116
+ -> src/app/chat + src/ui/rustChatHost + crates/ufoo-tui
117
117
  -> project daemon over .ufoo/run/ufoo.sock
118
118
  -> runtime daemon launch/resume/recover/reports/cron/groups
119
119
  -> orchestration router, group templates, solo roles
@@ -314,7 +314,8 @@ Global `ucode` settings:
314
314
  ```text
315
315
  src/
316
316
  app/ chat client state and CLI command entry
317
- ui/ Ink components and pure formatting helpers
317
+ ui/ Rust TUI hosts + pure formatting helpers
318
+ crates/ufoo-tui Rust ratatui child (required for chat/ucode)
318
319
  runtime/ daemon, projects, terminal adapters, contracts, privacy, process helpers
319
320
  coordination/ bus, context, memory, history, reports, state, status
320
321
  orchestration/ router/controller logic, groups, solo roles
@@ -343,23 +344,38 @@ npm run test:watch
343
344
  npm run test:coverage
344
345
  ```
345
346
 
346
- The repository is CommonJS, targets Node.js 18+, and has no build step.
347
+ The repository is CommonJS, targets Node.js 18+, and ships a Rust TTY UI
348
+ (`crates/ufoo-tui`) as platform binaries under `dist/tui/`.
347
349
 
348
350
  ## Release
349
351
 
350
- Use the standard npm flow from a clean worktree:
352
+ Releases are published by GitHub Actions (`.github/workflows/release.yml`).
353
+ The workflow builds `ufoo-tui` for `darwin-arm64`, `darwin-x64`, `linux-x64`,
354
+ and `linux-arm64`, stages them under `dist/tui/<platform>/`, then runs
355
+ `npm publish`.
356
+
357
+ 1. Bump the version and push a matching tag:
351
358
 
352
359
  ```bash
353
360
  npm test
354
- npm pack --dry-run
355
- npm version patch
356
- npm publish --access public
361
+ npm version patch # commits package.json + creates tag vX.Y.Z
357
362
  git push --follow-tags
358
363
  ```
359
364
 
360
- `npm pack --dry-run` should be used to verify the final tarball. Publishing
361
- requires an npm account/token with permission for `u-foo`.
365
+ 2. Ensure the repo secret `NPM_TOKEN` is set (npm automation token with
366
+ publish rights for `u-foo`).
367
+ 3. The `Release` workflow runs on the `v*` tag. Use **Actions → Release →
368
+ Run workflow** for a manual dry-run (`dry_run=true` packs but does not publish).
369
+
370
+ Local one-platform staging (dev only):
371
+
372
+ ```bash
373
+ npm run pack:tui # cargo build + copy into dist/tui/$PLATFORM
374
+ npm pack --dry-run # requires at least one staged binary (prepack check)
375
+ ```
362
376
 
377
+ Publishing without the GitHub workflow still requires an npm account/token with
378
+ permission for `u-foo`, plus staged `dist/tui/` binaries.
363
379
  ## Troubleshooting
364
380
 
365
381
  Run a local entry directly if the linked binary is not on `PATH`:
package/README.zh-CN.md CHANGED
@@ -110,7 +110,7 @@ MCP bridge 会连接 home 级 global controller daemon,并通过全局项目 r
110
110
 
111
111
  ```text
112
112
  ufoo / ufoo chat
113
- -> src/app/chat + src/ui/ink
113
+ -> src/app/chat + src/ui/rustChatHost + crates/ufoo-tui
114
114
  -> project daemon over .ufoo/run/ufoo.sock
115
115
  -> runtime daemon launch/resume/recover/reports/cron/groups
116
116
  -> orchestration router, group templates, solo roles
@@ -306,7 +306,8 @@ ufoo ucode build
306
306
  ```text
307
307
  src/
308
308
  app/ chat client state 和 CLI command entry
309
- ui/ Ink components 和纯格式化 helper
309
+ ui/ Rust TUI hosts + 纯格式化 helper
310
+ crates/ufoo-tui Rust ratatui 子进程(chat/ucode 必需)
310
311
  runtime/ daemon、projects、terminal adapters、contracts、privacy、process helpers
311
312
  coordination/ bus、context、memory、history、reports、state、status
312
313
  orchestration/ router/controller logic、groups、solo roles
@@ -336,22 +337,34 @@ npm run test:coverage
336
337
  npm run bench:global-switch
337
338
  ```
338
339
 
339
- 本仓库是 CommonJS,目标 Node.js 18+,没有 build step。
340
+ 本仓库是 CommonJS,目标 Node.js 18+,并通过 `dist/tui/` 附带 Rust TTY UI
341
+ (`crates/ufoo-tui`)的平台二进制。
340
342
 
341
343
  ## 发布
342
344
 
343
- 请在干净工作区按标准 npm 流程发布:
345
+ 发布由 GitHub Actions(`.github/workflows/release.yml`)完成:多平台编译
346
+ `ufoo-tui`(`darwin-arm64` / `darwin-x64` / `linux-x64` / `linux-arm64`),
347
+ 写入 `dist/tui/<platform>/`,再执行 `npm publish`。
348
+
349
+ 1. 升版本并推送对应 tag:
344
350
 
345
351
  ```bash
346
352
  npm test
347
- npm pack --dry-run
348
- npm version patch
349
- npm publish --access public
353
+ npm version patch # 提交 package.json 并创建 tag vX.Y.Z
350
354
  git push --follow-tags
351
355
  ```
352
356
 
353
- 发布前用 `npm pack --dry-run` 检查最终 tarball。发布需要有 `u-foo` 权限的
354
- npm 账号/token。
357
+ 2. 仓库需配置 secret `NPM_TOKEN`(对 `u-foo` 有发布权限的 npm automation token)。
358
+ 3. 推送 `v*` tag 后自动跑 `Release` workflow。也可在 Actions 里手动
359
+ **Run workflow**(`dry_run=true` 只打包不发布)。
360
+
361
+ 本地单平台预演:
362
+
363
+ ```bash
364
+ npm run pack:tui
365
+ npm pack --dry-run
366
+ ```
367
+
355
368
 
356
369
  ## 故障排查
357
370
 
@@ -1,18 +1,19 @@
1
1
  ---
2
2
  name: ubus
3
3
  description: |
4
- Poll event bus, check pending messages.
5
- Use when: (1) check if other Agents sent messages, (2) view bus status, (3) periodic polling.
4
+ Check and handle pending event-bus messages when /ubus is explicitly invoked.
5
+ Use when: (1) asked to check messages, (2) view bus status, (3) use watch/listen/auto modes.
6
6
  If not yet joined bus, will auto-join.
7
7
  ---
8
8
 
9
- # /ubus - Event Bus Polling
9
+ # /ubus - Check Event Bus Messages
10
10
 
11
- Check pending messages on the event bus.
11
+ Check and handle pending messages on the event bus when `/ubus` is explicitly
12
+ invoked.
12
13
 
13
14
  ## Arguments
14
15
 
15
- - `/ubus` - Check messages and show status
16
+ - `/ubus` - Pull pending messages and show status
16
17
  - `/ubus watch` - Start background auto-notification (title badge + bell + notification center)
17
18
  - `/ubus stop` - Stop background auto-notification
18
19
  - `/ubus listen` - Foreground continuous listener, print new messages (suitable for side terminal)
@@ -74,7 +75,7 @@ If argument is `listen`, foreground blocking listener (no background task tool n
74
75
  ufoo bus listen "$SUBSCRIBER" --from-beginning
75
76
  ```
76
77
 
77
- If argument is `auto`, unattended auto-execute:
78
+ If argument is `auto`, use unattended auto-execute:
78
79
 
79
80
  ```bash
80
81
  # Start daemon (background resident), auto-inject /ubus + Enter on new message
@@ -118,7 +119,8 @@ After you have read and processed the messages, you MUST acknowledge them to pre
118
119
  ufoo bus ack "$SUBSCRIBER"
119
120
  ```
120
121
 
121
- **This is critical** - if you don't ack, the daemon will keep injecting `/ubus` commands.
122
+ **This is critical** - if you don't ack, the runtime may retry delivery or keep
123
+ the event pending.
122
124
 
123
125
  **Default behavior is ack-only, no reply.** If there's nothing to do (no actionable task, no question to answer, no follow-up the sender genuinely needs), just ack and stop. Silence is a valid response — see "Handling Received Messages" below for when a reply IS warranted.
124
126
 
@@ -196,11 +198,15 @@ and the two of you will ping-pong forever.
196
198
  | `这个 bug 的根因是什么?` (question) | ✅ reply with answer |
197
199
  | `我帮你找到了 X,需要你做 Y` (request) | ✅ reply with status |
198
200
 
199
- When in doubt: ack and wait. If the sender genuinely needs something
200
- from you, they will follow up with a concrete question or task.
201
+ When in doubt: ack and stop. If the sender genuinely needs something from you,
202
+ they will follow up with a concrete question or task.
201
203
 
202
204
  ## Sending Messages
203
205
 
206
+ After sending a message, do not run `/ubus`, poll, sleep, or wait for a reply.
207
+ Continue the current task. Any follow-up message will be automatically injected
208
+ into your prompt/session.
209
+
204
210
  ### Smart Routing (when you don't know the target ID)
205
211
 
206
212
  If the user says "notify codex to do X" without specifying an ID, use smart routing:
@@ -117,11 +117,21 @@ ufoo memory audit mem-0001
117
117
 
118
118
  ## 3. Event Bus (ubus)
119
119
 
120
+ ### Delivery Rule
121
+
122
+ After `ufoo bus send` or `ufoo bus broadcast`, do not run
123
+ `ufoo bus check`, invoke `/ubus`, sleep, or wait for a reply. Continue the
124
+ current task. Any follow-up message will be automatically injected into your
125
+ prompt/session.
126
+
127
+ When `/ubus` is explicitly requested, follow its pending-message flow
128
+ immediately. Do not invoke it merely to wait for a reply to a message you sent.
129
+
120
130
  ### Commands
121
131
 
122
132
  ```bash
123
- ufoo bus check "$UFOO_SUBSCRIBER_ID" # Check pending messages
124
- ufoo bus ack "$UFOO_SUBSCRIBER_ID" # Acknowledge after handling
133
+ ufoo bus check "$UFOO_SUBSCRIBER_ID" # Used by an explicit /ubus request
134
+ ufoo bus ack "$UFOO_SUBSCRIBER_ID" # Acknowledge after handling
125
135
  ufoo bus send "<target>" "<message>" # Send message
126
136
  ufoo bus broadcast "<message>" # Broadcast to all
127
137
  ufoo bus status # Show bus status
@@ -149,13 +159,13 @@ Notes:
149
159
  - Type: `codex` (all codex agents)
150
160
  - Wildcard: `*` (broadcast)
151
161
 
152
- ### CRITICAL: When you receive pending messages
162
+ ### CRITICAL: When a message is received
153
163
 
154
164
  **EXECUTE tasks immediately. Do NOT ask the user.**
155
165
 
156
- 1. Check: `ufoo bus check $UFOO_SUBSCRIBER_ID`
157
- 2. Execute each task
158
- 3. **Always ack**: `ufoo bus ack $UFOO_SUBSCRIBER_ID`
166
+ 1. Read the available `[ufoo]<from:...>` message.
167
+ 2. Execute each task.
168
+ 3. **Always ack after handling**: `ufoo bus ack $UFOO_SUBSCRIBER_ID`
159
169
  4. Reply only when substantive — send `ufoo bus send "<publisher>" "<result>"` only if:
160
170
  - The sender asked a question → reply with the answer
161
171
  - The sender delegated a task → reply with the result / artifact
@@ -164,6 +174,10 @@ Notes:
164
174
  5. **Report** if the work arrived from `[manual]<to:...>` or `[ufoo]<from:...>`:
165
175
  `ufoo report progress|done|error "<short summary>"`
166
176
 
177
+ If `/ubus` is explicitly requested, run
178
+ `ufoo bus check "$UFOO_SUBSCRIBER_ID"` and then follow the same execute → ack
179
+ flow.
180
+
167
181
  ---
168
182
 
169
183
  ## 4. Message Format
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.10",
3
+ "version": "3.0.12",
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
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Icyoung/ufoo.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Icyoung/ufoo/issues"
13
+ },
7
14
  "author": "UFOO Team",
8
15
  "keywords": [
9
16
  "cli",
@@ -29,6 +36,7 @@
29
36
  "templates/",
30
37
  "online/",
31
38
  "scripts/",
39
+ "dist/tui/",
32
40
  "SKILLS/",
33
41
  "LICENSE",
34
42
  "README.md"
@@ -39,6 +47,8 @@
39
47
  },
40
48
  "scripts": {
41
49
  "postinstall": "node scripts/postinstall.js",
50
+ "pack:tui": "node scripts/pack-tui.js",
51
+ "prepack": "node scripts/pack-tui.js --check",
42
52
  "test": "jest",
43
53
  "test:watch": "jest --watch",
44
54
  "test:coverage": "jest --coverage"
@@ -51,9 +61,7 @@
51
61
  "chalk": "^4.1.2",
52
62
  "commander": "^13.1.0",
53
63
  "gray-matter": "^4.0.3",
54
- "ink": "^5.2.1",
55
64
  "node-pty": "^1.1.0",
56
- "react": "^18.3.1",
57
65
  "ws": "^8.19.0"
58
66
  },
59
67
  "devDependencies": {
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ "use strict";
4
+
5
+ /**
6
+ * Stage a release `ufoo-tui` binary under dist/tui/<platform>-<arch>/.
7
+ *
8
+ * Usage:
9
+ * node scripts/pack-tui.js # build current host + stage
10
+ * node scripts/pack-tui.js --copy-only # stage from existing target/release
11
+ * node scripts/pack-tui.js --check # exit 0 only if at least one staged binary exists
12
+ */
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const { spawnSync } = require("child_process");
17
+
18
+ const ROOT = path.resolve(__dirname, "..");
19
+ const DIST_ROOT = path.join(ROOT, "dist", "tui");
20
+ const BINARY_NAME = process.platform === "win32" ? "ufoo-tui.exe" : "ufoo-tui";
21
+
22
+ function hostPlatform() {
23
+ return `${process.platform}-${process.arch}`;
24
+ }
25
+
26
+ function listStagedPlatforms() {
27
+ if (!fs.existsSync(DIST_ROOT)) return [];
28
+ const out = [];
29
+ for (const entry of fs.readdirSync(DIST_ROOT, { withFileTypes: true })) {
30
+ if (!entry.isDirectory()) continue;
31
+ const bin = path.join(DIST_ROOT, entry.name, "ufoo-tui");
32
+ const binExe = path.join(DIST_ROOT, entry.name, "ufoo-tui.exe");
33
+ if (fs.existsSync(bin) || fs.existsSync(binExe)) out.push(entry.name);
34
+ }
35
+ return out.sort();
36
+ }
37
+
38
+ function findReleaseBinary() {
39
+ const candidates = [
40
+ path.join(ROOT, "target", "release", BINARY_NAME),
41
+ path.join(ROOT, "crates", "ufoo-tui", "target", "release", BINARY_NAME),
42
+ ];
43
+ for (const candidate of candidates) {
44
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
45
+ return candidate;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function buildRelease() {
52
+ console.log("[pack-tui] cargo build -p ufoo-tui --release");
53
+ const result = spawnSync("cargo", ["build", "-p", "ufoo-tui", "--release"], {
54
+ cwd: ROOT,
55
+ stdio: "inherit",
56
+ env: process.env,
57
+ });
58
+ if (result.status !== 0) {
59
+ throw new Error("cargo build failed");
60
+ }
61
+ }
62
+
63
+ function stageBinary(sourcePath, platform = hostPlatform()) {
64
+ const destDir = path.join(DIST_ROOT, platform);
65
+ fs.mkdirSync(destDir, { recursive: true });
66
+ const destName = platform.startsWith("win32") ? "ufoo-tui.exe" : "ufoo-tui";
67
+ const destPath = path.join(destDir, destName);
68
+ fs.copyFileSync(sourcePath, destPath);
69
+ try {
70
+ fs.chmodSync(destPath, 0o755);
71
+ } catch {
72
+ // Windows may ignore chmod
73
+ }
74
+ console.log(`[pack-tui] staged ${destPath}`);
75
+ return destPath;
76
+ }
77
+
78
+ function main(argv = process.argv.slice(2)) {
79
+ const copyOnly = argv.includes("--copy-only");
80
+ const checkOnly = argv.includes("--check");
81
+
82
+ if (checkOnly) {
83
+ const plats = listStagedPlatforms();
84
+ if (plats.length === 0) {
85
+ console.error("[pack-tui] no staged binaries under dist/tui/<platform>/");
86
+ process.exit(1);
87
+ }
88
+ console.log(`[pack-tui] staged platforms: ${plats.join(", ")}`);
89
+ return;
90
+ }
91
+
92
+ if (!copyOnly) {
93
+ buildRelease();
94
+ }
95
+
96
+ const source = findReleaseBinary();
97
+ if (!source) {
98
+ throw new Error(
99
+ `release binary not found (looked for target/release/${BINARY_NAME}). `
100
+ + "Run cargo build -p ufoo-tui --release first.",
101
+ );
102
+ }
103
+ stageBinary(source);
104
+ console.log(`[pack-tui] ok (${hostPlatform()})`);
105
+ }
106
+
107
+ try {
108
+ main();
109
+ } catch (err) {
110
+ console.error(`[pack-tui] ${err && err.message ? err.message : err}`);
111
+ process.exit(1);
112
+ }
@@ -30,6 +30,17 @@ for (const platform of platforms) {
30
30
  }
31
31
  }
32
32
 
33
+ // Ensure the platform ufoo-tui binary from the npm pack is executable.
34
+ try {
35
+ const plat = `${process.platform}-${process.arch}`;
36
+ const tuiBin = path.join(__dirname, "..", "dist", "tui", plat, "ufoo-tui");
37
+ if (fs.existsSync(tuiBin)) {
38
+ fs.chmodSync(tuiBin, 0o755);
39
+ }
40
+ } catch {
41
+ // Non-fatal — doctor / launch will report missing binary.
42
+ }
43
+
33
44
  // Collect all skill sources from the package-level SKILLS directory.
34
45
  function collectSkillSources(pkgRoot) {
35
46
  const sources = [];
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { readJSON } = require("../../coordination/bus/utils");
5
+
6
+ const DEFAULT_GRACE_MS = 5000;
7
+ const BUSY_DISK_STATES = new Set(["working", "waiting_input", "blocked"]);
8
+ const DELIVERABLE_LOCAL_STATES = new Set(["idle", "ready"]);
9
+
10
+ function asState(value = "") {
11
+ return String(value || "").trim().toLowerCase();
12
+ }
13
+
14
+ function resolveGraceMs(detector, options = {}) {
15
+ if (Number.isFinite(options.graceMs) && options.graceMs > 0) {
16
+ return options.graceMs;
17
+ }
18
+ const quiet = Number(detector && detector.quietWindowMs);
19
+ const base = Number.isFinite(quiet) && quiet > 0 ? quiet : DEFAULT_GRACE_MS;
20
+ // Cap so internal-pty (30s quiet) does not leave inject stamps stuck that long.
21
+ return Math.min(base, DEFAULT_GRACE_MS);
22
+ }
23
+
24
+ function readDiskMeta(agentsFile, subscriber) {
25
+ if (!agentsFile || !subscriber || !fs.existsSync(agentsFile)) return null;
26
+ try {
27
+ const data = readJSON(agentsFile, null);
28
+ const meta = data && data.agents && data.agents[subscriber];
29
+ return meta && typeof meta === "object" ? meta : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Heal false `working` when the in-process ActivityDetector already believes
37
+ * the agent is deliverable (idle/ready) but all-agents.json still says busy.
38
+ *
39
+ * Typical cause: DeliveryScheduler stamps `working` (detail=inject) after
40
+ * inject while the detector never saw meaningful PTY output, so it never
41
+ * re-publishes idle (publisher dedupe + no state transition).
42
+ *
43
+ * Waits `graceMs` (≤ quiet window, capped at 5s) after disk `activity_since`
44
+ * so the inject stamp can still close the mid-turn double-inject race.
45
+ *
46
+ * Does not clear `waiting_input` / `blocked` — those need explicit recovery.
47
+ *
48
+ * @returns {{ ok: boolean, reconciled: boolean, reason: string }}
49
+ */
50
+ function reconcileDetectorOwnedActivity(options = {}) {
51
+ const {
52
+ detector,
53
+ publisher,
54
+ agentsFile,
55
+ subscriber,
56
+ now = Date.now(),
57
+ } = options;
58
+
59
+ if (!detector || !publisher || !agentsFile || !subscriber) {
60
+ return { ok: false, reconciled: false, reason: "missing" };
61
+ }
62
+
63
+ const snap = typeof detector.getState === "function"
64
+ ? detector.getState()
65
+ : { state: "", since: now, detail: "" };
66
+ const local = asState(snap.state);
67
+ if (!DELIVERABLE_LOCAL_STATES.has(local)) {
68
+ return { ok: true, reconciled: false, reason: "local_busy" };
69
+ }
70
+
71
+ const diskMeta = readDiskMeta(agentsFile, subscriber);
72
+ if (!diskMeta) {
73
+ return { ok: false, reconciled: false, reason: "missing_agent" };
74
+ }
75
+
76
+ const diskState = asState(diskMeta.activity_state);
77
+ if (diskState !== "working") {
78
+ return { ok: true, reconciled: false, reason: BUSY_DISK_STATES.has(diskState) ? "disk_gated" : "disk_ok" };
79
+ }
80
+
81
+ const graceMs = resolveGraceMs(detector, options);
82
+ const sinceMs = Date.parse(String(diskMeta.activity_since || ""));
83
+ const diskAge = Number.isFinite(sinceMs) ? Math.max(0, now - sinceMs) : graceMs;
84
+ if (diskAge < graceMs) {
85
+ return { ok: true, reconciled: false, reason: "grace" };
86
+ }
87
+
88
+ const publishState = local === "ready" ? "idle" : local;
89
+ const changed = publisher.publish(publishState, {
90
+ since: snap.since,
91
+ previous: diskState,
92
+ detail: "",
93
+ }, { force: true, reconcile: true });
94
+
95
+ return {
96
+ ok: true,
97
+ reconciled: Boolean(changed),
98
+ reason: changed ? "cleared" : "unchanged",
99
+ };
100
+ }
101
+
102
+ module.exports = {
103
+ DEFAULT_GRACE_MS,
104
+ reconcileDetectorOwnedActivity,
105
+ resolveGraceMs,
106
+ };
@@ -1,5 +1,8 @@
1
+ "use strict";
2
+
1
3
  const fs = require("fs");
2
4
  const path = require("path");
5
+ const { readJSON } = require("../../coordination/bus/utils");
3
6
  const { writeActivityState } = require("./activityStateWriter");
4
7
 
5
8
  /**
@@ -16,7 +19,9 @@ const { writeActivityState } = require("./activityStateWriter");
16
19
  * @param {string} options.subscriber - Subscriber ID (e.g. "claude-code:abc123")
17
20
  * @param {string} options.projectRoot - Project root (unused, kept for API compat)
18
21
  * @param {boolean} [options.force=true] - Force overwrite priority-protected states
19
- * publish(state, extra, { force }) can override this default for one transition.
22
+ * publish(state, extra, { force, reconcile }) can override defaults for one
23
+ * transition. `reconcile: true` skips in-memory dedupe when disk disagrees
24
+ * (daemon inject stamp / lost-update healing).
20
25
  */
21
26
  function createActivityStatePublisher(options = {}) {
22
27
  const {
@@ -28,9 +33,33 @@ function createActivityStatePublisher(options = {}) {
28
33
  let lastState = "";
29
34
  let lastDetail = "";
30
35
 
36
+ function readDiskActivity() {
37
+ try {
38
+ if (!agentsFile || !fs.existsSync(agentsFile)) return null;
39
+ const data = readJSON(agentsFile, null);
40
+ const meta = data && data.agents && data.agents[subscriber];
41
+ if (!meta || typeof meta !== "object") return null;
42
+ return {
43
+ state: typeof meta.activity_state === "string" ? meta.activity_state : "",
44
+ detail: typeof meta.activity_detail === "string" ? meta.activity_detail : "",
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
31
51
  function publish(state, extra = {}, publishOptions = {}) {
32
52
  const detail = typeof extra.detail === "string" ? extra.detail : "";
33
- if (state === lastState && detail === lastDetail) return false;
53
+ const reconcile = publishOptions.reconcile === true;
54
+ if (!reconcile && state === lastState && detail === lastDetail) return false;
55
+ if (reconcile) {
56
+ const disk = readDiskActivity();
57
+ if (disk && disk.state === state && disk.detail === detail) {
58
+ lastState = state;
59
+ lastDetail = detail;
60
+ return false;
61
+ }
62
+ }
34
63
  const since = extra.since || undefined;
35
64
  const effectiveForce = typeof publishOptions.force === "boolean"
36
65
  ? publishOptions.force
@@ -5,4 +5,5 @@ module.exports = {
5
5
  ...require("./activityStatePublisher"),
6
6
  ...require("./activityStateWriter"),
7
7
  ...require("./activityTracker"),
8
+ ...require("./activityReconcile"),
8
9
  };
@@ -10,6 +10,7 @@ const { showBanner } = require("../../ui/format/banner");
10
10
  const AgentNotifier = require("./notifier");
11
11
  const { ActivityDetector } = require("../activity/activityDetector");
12
12
  const { createActivityStatePublisher } = require("../activity/activityStatePublisher");
13
+ const { reconcileDetectorOwnedActivity } = require("../activity/activityReconcile");
13
14
  const { detectLaunchEnvironment } = require("./launchEnvironment");
14
15
  const { getUfooPaths } = require("../../coordination/state/paths");
15
16
  const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
@@ -697,6 +698,23 @@ class AgentLauncher {
697
698
  detail: snap.detail,
698
699
  });
699
700
  });
701
+ // Heal daemon inject-stamp desync (disk working, detector idle).
702
+ const agentsFilePath = getUfooPaths(this.cwd).agentsFile;
703
+ const activityReconcileTimer = setInterval(() => {
704
+ try {
705
+ reconcileDetectorOwnedActivity({
706
+ detector: launcherActivityDetector,
707
+ publisher: launcherPublisher,
708
+ agentsFile: agentsFilePath,
709
+ subscriber: subscriberId,
710
+ });
711
+ } catch {
712
+ // reconcile must never break the launcher
713
+ }
714
+ }, 2000);
715
+ if (typeof activityReconcileTimer.unref === "function") {
716
+ activityReconcileTimer.unref();
717
+ }
700
718
  // Tail buffer for agy: when the TUI exits, agy prints a single line
701
719
  // `Resume: agy --conversation=<UUID> (or -c)` to stdout. We keep
702
720
  // the trailing ~4KB of post-frame output around and grep it on exit
@@ -763,6 +781,7 @@ class AgentLauncher {
763
781
  wrapper.onExit = async ({ exitCode, signal }) => {
764
782
  // 清理 timers
765
783
  clearTimeout(forceReadyTimer);
784
+ clearInterval(activityReconcileTimer);
766
785
  launcherActivityDetector.destroy();
767
786
 
768
787
  // Agy: harvest conversation id from the trailing `Resume: agy