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 +1 -1
- package/src/agent/activityStatePublisher.js +19 -4
- package/src/agent/activityStateWriter.js +29 -13
- package/src/agent/activityTracker.js +147 -0
- package/src/agent/internalRunner.js +52 -9
- package/src/agent/launchEnvironment.js +64 -0
- package/src/agent/launcher.js +2 -12
- package/src/chat/agentViewController.js +156 -2
- package/src/chat/daemonMessageRouter.js +13 -1
- package/src/chat/index.js +43 -7
- package/src/chat/transientAgentState.js +32 -9
- package/src/daemon/ops.js +13 -2
- package/src/daemon/status.js +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
|
|
32
|
-
if (current === state) return false;
|
|
37
|
+
if (currentState === state && currentDetail === detail) return false;
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/agent/launcher.js
CHANGED
|
@@ -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
|
-
|
|
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(-
|
|
462
|
-
for (let i = 0; i <
|
|
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
|
-
|
|
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
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
30
|
+
updatedAt: options.now,
|
|
31
|
+
detail: options.detail,
|
|
17
32
|
});
|
|
18
33
|
}
|
|
19
34
|
|
|
20
|
-
function
|
|
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
|
-
|
|
171
|
-
|
|
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
|
}
|
package/src/daemon/status.js
CHANGED
|
@@ -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 || "",
|