loadtoagent 1.3.10 → 1.3.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.
- package/README.ko.md +6 -3
- package/README.md +6 -3
- package/README.zh-CN.md +6 -3
- package/bin/loadtoagent.js +3 -1
- package/docs/ARCHITECTURE.md +7 -0
- package/docs/MANAGED-TERMINAL-SESSIONS.md +64 -0
- package/main.js +5 -1
- package/package.json +2 -1
- package/preload.js +3 -0
- package/renderer/app-agent-actions.js +83 -35
- package/renderer/app-dashboard.js +41 -3
- package/renderer/app-drawer-content.js +4 -1
- package/renderer/app-drawer.js +107 -26
- package/renderer/app-events-dialogs.js +66 -8
- package/renderer/app-events-filters.js +3 -1
- package/renderer/app-events-sessions.js +3 -23
- package/renderer/app-graph-orchestration.js +6 -4
- package/renderer/app-graph-view.js +74 -16
- package/renderer/app-session-render.js +5 -1
- package/renderer/app.js +11 -1
- package/renderer/i18n-messages.js +35 -0
- package/renderer/index.html +2 -0
- package/renderer/styles-components.css +48 -0
- package/renderer/styles-control-room.css +444 -0
- package/renderer/styles-terminal.css +6 -0
- package/renderer/terminal-events.js +30 -4
- package/renderer/terminal-workbench.js +15 -3
- package/renderer/terminal.js +2 -0
- package/src/agentMonitor/codexParser.js +6 -2
- package/src/agentMonitor/executionActivity.js +3 -4
- package/src/bridgeServer.js +1 -1
- package/src/ipc/registerTerminalIpc.js +1 -1
- package/src/managedTmuxRuntime.js +53 -0
- package/src/terminalHost.js +8 -2
- package/src/terminalManager.js +173 -33
package/README.ko.md
CHANGED
|
@@ -70,7 +70,7 @@ LoadToAgent는 시작할 때 현재 패키지 버전과 GitHub의 최신 정식
|
|
|
70
70
|
- macOS 또는 Windows
|
|
71
71
|
- npm으로 설치할 때만 Node.js 18 이상
|
|
72
72
|
- Claude Code, Codex CLI, Gemini CLI, Grok CLI 중 하나 이상 설치 및 로그인
|
|
73
|
-
-
|
|
73
|
+
- macOS의 지속형 AI 세션 또는 Windows WSL 관리형 세션에는 tmux 필요. Windows 네이티브 AI 세션과 일반 명령창은 기존 직접 PTY 방식을 사용합니다.
|
|
74
74
|
|
|
75
75
|
## 처음 10분 사용법
|
|
76
76
|
|
|
@@ -130,9 +130,11 @@ loadtoagent run grok
|
|
|
130
130
|
loadtoagent run claude -- --model claude-sonnet-4-6
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
이제 외부 터미널과 LoadToAgent 대시보드가 같은 LoadToAgent 전용
|
|
133
|
+
이제 외부 터미널과 LoadToAgent 대시보드가 같은 LoadToAgent 전용 세션을 조작합니다. AI 카드에서 `터미널에서 열기`를 누르면 새 셸을 만들지 않고 정확히 연결된 기존 터미널을 열며, 왼쪽에는 해당 세션의 이전 대화가 계속 표시됩니다. 터미널 탭을 바꿔도 출력은 지워지지 않고, 최신 대화 기록도 자동으로 갱신됩니다. 다른 곳에서 임의로 시작한 세션은 계속 볼 수 있지만, 원래 앱이 지원하는 연결 방식이 없으면 보기 전용으로 유지됩니다.
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
macOS와 WSL의 지속형 AI 터미널은 개인 tmux와 분리된 `tmux -L loadtoagent` 서버에서 실행됩니다. `터미널 화면 닫기`는 attach 화면만 분리하고 AI 작업은 백그라운드에서 계속합니다. 목록의 `기존 작업 다시 연결`은 새 AI 대화를 만들지 않고 같은 tmux 세션과 LoadToAgent 세션 ID에 다시 붙습니다. `AI 세션 종료`는 실제 tmux 작업을 끝내되 확인할 수 있도록 기록을 남기며, 중지된 기록은 별도로 제거할 수 있습니다.
|
|
136
|
+
|
|
137
|
+
대시보드나 터미널 호스트가 예기치 않게 끝나도 tmux 작업이 살아 있으면 다음 실행에서 같은 세션으로 복구합니다. 저장된 tmux가 사라졌다면 중복 AI 대화를 자동 생성하지 않고 `작업 중지됨`으로 표시합니다. Windows 네이티브 AI 세션과 일반 명령창은 기존 직접 PTY/터미널 호스트 방식을 유지합니다. 두 방식 모두 실행 중·분리됨·자연 종료·시작 실패 기록은 사용자가 명시적으로 제거할 때까지 세션 터미널 목록에 남습니다.
|
|
136
138
|
|
|
137
139
|
## 로컬 퍼스트와 보안
|
|
138
140
|
|
|
@@ -156,6 +158,7 @@ npm test
|
|
|
156
158
|
|
|
157
159
|
```bash
|
|
158
160
|
npm run test:terminal
|
|
161
|
+
npm run test:terminal:managed
|
|
159
162
|
npm run test:bridge
|
|
160
163
|
npm run test:tmux -- macOS
|
|
161
164
|
npm run test:visual
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ On startup, LoadToAgent compares its package version with the newest stable GitH
|
|
|
70
70
|
- macOS or Windows
|
|
71
71
|
- Node.js 18 or newer only when installing through npm
|
|
72
72
|
- At least one installed and authenticated CLI: Claude Code, Codex CLI, Gemini CLI, or Grok CLI
|
|
73
|
-
- tmux
|
|
73
|
+
- tmux for persistent AI sessions on macOS or managed WSL sessions on Windows. Native Windows AI sessions and ordinary shells keep using the direct PTY backend.
|
|
74
74
|
|
|
75
75
|
## Your first 10 minutes
|
|
76
76
|
|
|
@@ -128,9 +128,11 @@ Arguments after `--` are passed to the provider CLI:
|
|
|
128
128
|
loadtoagent run claude -- --model claude-sonnet-4-6
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
The external terminal and LoadToAgent dashboard control the same LoadToAgent-owned
|
|
131
|
+
The external terminal and LoadToAgent dashboard control the same LoadToAgent-owned session. Opening a terminal from an AI card reuses the exact connected terminal instead of creating a new shell, keeps its output intact across UI navigation, and shows that session's prior conversation in a live side rail. Sessions started arbitrarily elsewhere remain visible but read-only unless the original app exposes a supported handoff.
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
Persistent AI terminals on macOS and WSL run on the isolated `tmux -L loadtoagent` server, separate from your personal tmux server. `Close terminal view` detaches only the attached view while the AI keeps working in the background. `Reconnect existing work` attaches to that same tmux session and LoadToAgent session ID without starting another AI conversation. `End AI session` stops the tmux work but keeps its record for inspection; a stopped record can then be removed separately.
|
|
134
|
+
|
|
135
|
+
If the dashboard or terminal host exits unexpectedly, the next host reconnects to the same session when its tmux work is still alive. If the stored tmux session is gone, LoadToAgent marks the record stopped instead of silently starting a duplicate conversation. Native Windows AI sessions and ordinary shells retain the direct PTY/terminal-host backend. In both backends, running, detached, naturally exited, and failed-start records remain in Session terminal until explicitly removed.
|
|
134
136
|
|
|
135
137
|
## Local-first by design
|
|
136
138
|
|
|
@@ -154,6 +156,7 @@ Additional checks and distributable builds:
|
|
|
154
156
|
|
|
155
157
|
```bash
|
|
156
158
|
npm run test:terminal
|
|
159
|
+
npm run test:terminal:managed
|
|
157
160
|
npm run test:bridge
|
|
158
161
|
npm run test:tmux -- macOS
|
|
159
162
|
npm run test:visual
|
package/README.zh-CN.md
CHANGED
|
@@ -70,7 +70,7 @@ LoadToAgent 启动时会比较当前包版本与最新的稳定 GitHub Release
|
|
|
70
70
|
- macOS 或 Windows
|
|
71
71
|
- 仅通过 npm 安装时需要 Node.js 18 或更高版本
|
|
72
72
|
- 至少安装并登录一个 CLI:Claude Code、Codex CLI、Gemini CLI 或 Grok CLI
|
|
73
|
-
-
|
|
73
|
+
- macOS 持久 AI 会话或 Windows WSL 托管会话需要 tmux。Windows 原生 AI 会话和普通命令行仍使用直接 PTY 后端。
|
|
74
74
|
|
|
75
75
|
## 前 10 分钟上手
|
|
76
76
|
|
|
@@ -128,9 +128,11 @@ loadtoagent run grok
|
|
|
128
128
|
loadtoagent run claude -- --model claude-sonnet-4-6
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
外部终端与 LoadToAgent 仪表盘会共同控制同一个 LoadToAgent
|
|
131
|
+
外部终端与 LoadToAgent 仪表盘会共同控制同一个 LoadToAgent 专用会话。在 AI 卡片中打开终端时,会复用准确的现有终端和会话 ID,不会创建新的 Shell;切换页面时输出也会保留。在其他地方启动的现有会话仍然可见,但除非原应用提供受支持的交接方式,否则会保持只读。
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
macOS 和 WSL 的持久 AI 终端运行在隔离的 `tmux -L loadtoagent` 服务器中,不会混入个人 tmux。`关闭终端视图`只会分离当前 attach 画面,AI 工作仍在后台继续。`重新连接现有工作`会连接到相同的 tmux 会话和 LoadToAgent 会话 ID,不会创建新的 AI 对话。`结束 AI 会话`会停止实际 tmux 工作但保留记录,随后可以单独移除已停止的记录。
|
|
134
|
+
|
|
135
|
+
即使仪表盘或终端主机意外退出,只要 tmux 工作仍然存在,下次启动就会恢复同一个会话。若保存的 tmux 会话已经消失,LoadToAgent 会将记录标记为已停止,而不会静默创建重复的 AI 对话。Windows 原生 AI 会话和普通命令行继续使用直接 PTY/终端主机方式。运行中、已分离、自然退出或启动失败的记录都会保留到用户明确移除为止。
|
|
134
136
|
|
|
135
137
|
## 本地优先与安全
|
|
136
138
|
|
|
@@ -154,6 +156,7 @@ npm test
|
|
|
154
156
|
|
|
155
157
|
```bash
|
|
156
158
|
npm run test:terminal
|
|
159
|
+
npm run test:terminal:managed
|
|
157
160
|
npm run test:bridge
|
|
158
161
|
npm run test:tmux -- macOS
|
|
159
162
|
npm run test:visual
|
package/bin/loadtoagent.js
CHANGED
|
@@ -140,7 +140,9 @@ function run(argv = process.argv.slice(2)) {
|
|
|
140
140
|
process.stdin.on('data', data => writeFrame(socket, { type: 'input', data: Buffer.from(data).toString('base64') }));
|
|
141
141
|
process.stdout.on('resize', sendResize);
|
|
142
142
|
} else if (message.type === 'output') process.stdout.write(Buffer.from(String(message.data || ''), 'base64'));
|
|
143
|
-
else if (message.type === 'state' &&
|
|
143
|
+
else if (message.type === 'state' && ['detached', 'stopped', 'exited', 'failed'].includes(message.status)) {
|
|
144
|
+
exitCode = Number(message.exitCode || 0);
|
|
145
|
+
}
|
|
144
146
|
else if (message.type === 'error') {
|
|
145
147
|
process.stderr.write(`\nLoadToAgent: ${message.message}\n`);
|
|
146
148
|
exitCode = 1;
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -25,6 +25,13 @@ Recoverable main-process failures go through `src/diagnostics.js`. Expected
|
|
|
25
25
|
best-effort cleanup is logged with an operation name, while user-visible IPC
|
|
26
26
|
failures are returned to the renderer and shown near the initiating action.
|
|
27
27
|
|
|
28
|
+
Persistent POSIX and WSL AI terminals use the managed-session contract in
|
|
29
|
+
`docs/MANAGED-TERMINAL-SESSIONS.md`. The PTY is only an attach view over a
|
|
30
|
+
session on the isolated `tmux -L loadtoagent` server. Session metadata persists
|
|
31
|
+
independently, so a host restart reconnects to a live tmux session instead of
|
|
32
|
+
starting a duplicate provider conversation. Native Windows and transient
|
|
33
|
+
commands remain on the direct PTY backend.
|
|
34
|
+
|
|
28
35
|
Regression tests are registered by feature suites in `scripts/tests/` and run
|
|
29
36
|
through a shared harness. Electron integration scripts cover renderer events,
|
|
30
37
|
responsive layouts, the terminal bridge, and real BrowserWindow interaction.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Managed terminal sessions
|
|
2
|
+
|
|
3
|
+
LoadToAgent separates an AI task from the terminal view attached to it. On
|
|
4
|
+
macOS, Linux, and Windows WSL, every non-transient AI terminal is created in a
|
|
5
|
+
named session on the isolated `tmux -L loadtoagent` server. The Electron PTY
|
|
6
|
+
attaches to that session; it does not own the AI process.
|
|
7
|
+
|
|
8
|
+
This boundary is intentionally limited to the Session terminal. The agent map,
|
|
9
|
+
parent/subagent flow, monitoring parsers, and conversation UI continue to use
|
|
10
|
+
their existing data contracts.
|
|
11
|
+
|
|
12
|
+
## Invariants
|
|
13
|
+
|
|
14
|
+
- Each managed session persists its backend, socket name, tmux session name,
|
|
15
|
+
provider arguments, working directory, and stable LoadToAgent session ID.
|
|
16
|
+
- The socket and session names are validated and passed as separate process
|
|
17
|
+
arguments. Provider commands are not joined into an interpolated shell
|
|
18
|
+
string.
|
|
19
|
+
- The isolated socket prevents LoadToAgent options and lifecycle commands from
|
|
20
|
+
modifying the user's default tmux server.
|
|
21
|
+
- `window-size` is set to `largest` so an additional client does not shrink the
|
|
22
|
+
shared agent workspace unexpectedly.
|
|
23
|
+
- Host recovery may attach only when the persisted tmux session still exists.
|
|
24
|
+
A missing session becomes `stopped`; recovery must never start a fresh,
|
|
25
|
+
duplicate AI conversation.
|
|
26
|
+
- Store version 1 sessions migrate to the direct backend. They are not silently
|
|
27
|
+
converted into new tmux conversations.
|
|
28
|
+
|
|
29
|
+
## Lifecycle
|
|
30
|
+
|
|
31
|
+
| State or action | PTY attach view | tmux work | Saved record |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| `running` | Attached | Running | Kept |
|
|
34
|
+
| Close terminal view / `detach` | Closed | Running | Kept as `detached` |
|
|
35
|
+
| `reconnect` | Reattached | Same existing work | Same ID |
|
|
36
|
+
| End AI session / `stop` | Closed | Stopped | Kept as `stopped` |
|
|
37
|
+
| Remove record / `close` | Closed | Stopped | Removed |
|
|
38
|
+
| Host crash | Lost | Unchanged | Reattached if tmux still exists |
|
|
39
|
+
|
|
40
|
+
An attach process that exits naturally is also checked against tmux. If the
|
|
41
|
+
session still exists, the record becomes `detached`; if the agent and tmux
|
|
42
|
+
session ended, it becomes `stopped`.
|
|
43
|
+
|
|
44
|
+
## Platform policy
|
|
45
|
+
|
|
46
|
+
| Environment | Persistent AI terminal | Ordinary or transient terminal |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| macOS / Linux | Managed tmux | Direct PTY |
|
|
49
|
+
| Windows with a selected WSL distro | Managed tmux inside that distro | Direct PTY where applicable |
|
|
50
|
+
| Native Windows | Direct PTY | Direct PTY |
|
|
51
|
+
|
|
52
|
+
The native Windows fallback is deliberate because tmux is not a native Windows
|
|
53
|
+
runtime. It preserves existing ConPTY support while WSL receives the managed
|
|
54
|
+
session semantics.
|
|
55
|
+
|
|
56
|
+
## Verification
|
|
57
|
+
|
|
58
|
+
`npm test` covers option normalization, store migration, isolated launch
|
|
59
|
+
arguments, detach/reconnect/stop/close semantics, host recovery, natural attach
|
|
60
|
+
exit, IPC, and renderer contracts.
|
|
61
|
+
|
|
62
|
+
`npm run test:terminal:managed` uses the installed tmux and real Electron
|
|
63
|
+
`node-pty` runtime to verify creation, output, detached background execution,
|
|
64
|
+
same-ID reconnection, `window-size largest`, stopping, and record removal.
|
package/main.js
CHANGED
|
@@ -245,7 +245,11 @@ function createWindow() {
|
|
|
245
245
|
|
|
246
246
|
function backgroundTerminalSessions() {
|
|
247
247
|
if (!terminalManager) return [];
|
|
248
|
-
return terminalManager.list().filter(session => !session.transient && (
|
|
248
|
+
return terminalManager.list().filter(session => !session.transient && (
|
|
249
|
+
session.status === 'running'
|
|
250
|
+
|| session.status === 'starting'
|
|
251
|
+
|| session.status === 'detached'
|
|
252
|
+
));
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
function visibleTerminalSessions(sessions) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loadtoagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.12",
|
|
4
4
|
"description": "A local desktop dashboard for Claude, Codex, Gemini, and Grok sessions.",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"author": "wincube AX",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"test": "node scripts/regression-test.js",
|
|
46
46
|
"test:accuracy": "node scripts/accuracy-evaluation.js",
|
|
47
47
|
"test:terminal": "electron scripts/terminal-integration-test.js",
|
|
48
|
+
"test:terminal:managed": "electron scripts/managed-tmux-integration-test.js",
|
|
48
49
|
"test:terminal:mac-stress": "node scripts/mac-terminal-stress-check.js",
|
|
49
50
|
"test:terminal-restart": "node scripts/terminal-restart-persistence-check.js",
|
|
50
51
|
"test:bridge": "electron scripts/bridge-integration-test.js",
|
package/preload.js
CHANGED
|
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld('loadtoagent', {
|
|
|
40
40
|
terminalResize: (id, cols, rows) => ipcRenderer.invoke('terminals:resize', id, cols, rows),
|
|
41
41
|
terminalSignal: (id, signal) => ipcRenderer.invoke('terminals:signal', id, signal),
|
|
42
42
|
terminalRestart: id => ipcRenderer.invoke('terminals:restart', id),
|
|
43
|
+
terminalReconnect: id => ipcRenderer.invoke('terminals:reconnect', id),
|
|
44
|
+
terminalDetach: id => ipcRenderer.invoke('terminals:detach', id),
|
|
45
|
+
terminalStop: id => ipcRenderer.invoke('terminals:stop', id),
|
|
43
46
|
terminalClose: id => ipcRenderer.invoke('terminals:close', id),
|
|
44
47
|
tmuxSendText: options => ipcRenderer.invoke('tmux:send-text', options),
|
|
45
48
|
tmuxSendKey: options => ipcRenderer.invoke('tmux:send-key', options),
|
|
@@ -164,15 +164,19 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
164
164
|
};
|
|
165
165
|
const { route, targetSession, targets, available: routeAvailable } = routeContext;
|
|
166
166
|
const mode = routingEnabled && !routeAvailable ? "ended" : agentControlMode(targetSession, targets);
|
|
167
|
+
const inputMode = options.conversation
|
|
168
|
+
? state.agentCommandInputModes.get(session.id) || "conversation"
|
|
169
|
+
: "terminal";
|
|
167
170
|
const relayed = routingEnabled && route === "parent" && routeAvailable;
|
|
168
171
|
const targetKey = agentCommandTargetKey(session, route);
|
|
169
172
|
const savedTarget = state.agentCommandTargets.get(targetKey) || "";
|
|
170
173
|
const relayTarget = relayed ? targets.find((target) => target.kind === "terminal") || targets[0] || null : null;
|
|
174
|
+
const automaticTarget = relayTarget || targets.find((item) => item.kind === "terminal") || targets[0] || null;
|
|
171
175
|
const targetId = targets.some((target) => target.id === savedTarget)
|
|
172
176
|
? savedTarget
|
|
173
177
|
: targets.length === 1
|
|
174
178
|
? targets[0].id
|
|
175
|
-
:
|
|
179
|
+
: options.conversation ? automaticTarget?.id || "" : "";
|
|
176
180
|
if (targetId) state.agentCommandTargets.set(targetKey, targetId);
|
|
177
181
|
const target = targets.find((item) => item.id === targetId) || null;
|
|
178
182
|
const draft = state.agentCommandDrafts.get(session.id) || "";
|
|
@@ -192,7 +196,7 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
192
196
|
: mode === "connect"
|
|
193
197
|
? t("agent.connect_status")
|
|
194
198
|
: window.LoadToAgentI18n.t("ui.ended_session");
|
|
195
|
-
const
|
|
199
|
+
const controlHelp = relayed
|
|
196
200
|
? t("agent.route_parent_inline_help")
|
|
197
201
|
: mode === "direct"
|
|
198
202
|
? t("agent.direct_help")
|
|
@@ -205,18 +209,13 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
205
209
|
: mode === "connect"
|
|
206
210
|
? t("agent.connect_help", { provider: targetSession.provider })
|
|
207
211
|
: agentResumeSupport(targetSession).reason || t("agent.resume_method_unknown");
|
|
208
|
-
const
|
|
209
|
-
?
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<small class="${routeAvailable ? "available" : "unavailable"}">${esc(t(routeAvailable
|
|
214
|
-
? route === "direct" ? "agent.route_direct_available" : "agent.route_parent_available"
|
|
215
|
-
: route === "direct" ? "agent.route_direct_unavailable" : "agent.route_parent_unavailable"))}</small>
|
|
216
|
-
</div>`
|
|
217
|
-
: "";
|
|
212
|
+
const help = options.conversation
|
|
213
|
+
? inputMode === "terminal"
|
|
214
|
+
? t("agent.terminal_input_help", { target: target?.label || providerInfo(targetSession.provider).label })
|
|
215
|
+
: t("agent.ai_input_help", { provider: providerInfo(session.provider).label })
|
|
216
|
+
: controlHelp;
|
|
218
217
|
const picker =
|
|
219
|
-
targets.length > 1 && !relayed
|
|
218
|
+
targets.length > 1 && !relayed && (!options.conversation || inputMode === "terminal")
|
|
220
219
|
? `<label class="agent-command-target">
|
|
221
220
|
<span>${esc(t("agent.target_terminal"))}</span>
|
|
222
221
|
<select data-agent-command-target="${esc(targetKey)}">
|
|
@@ -226,38 +225,78 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
226
225
|
</label>`
|
|
227
226
|
: "";
|
|
228
227
|
const actions = relayed
|
|
229
|
-
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
228
|
+
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
229
|
+
? "agent.sending"
|
|
230
|
+
: inputMode === "terminal" ? "agent.send_terminal" : "agent.send_via_parent"))}</button>`
|
|
230
231
|
: mode === "direct"
|
|
231
|
-
? `<button type="button" data-agent-terminal-open="${esc(session.id)}" ${canSend ? "" : "disabled"}>${esc(t("agent.open_terminal"))}</button
|
|
232
|
-
<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
232
|
+
? `${options.conversation ? "" : `<button type="button" data-agent-terminal-open="${esc(session.id)}" ${canSend ? "" : "disabled"}>${esc(t("agent.open_terminal"))}</button>`}
|
|
233
|
+
<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
234
|
+
? "agent.sending"
|
|
235
|
+
: inputMode === "terminal" ? "agent.send_terminal" : options.conversation ? "agent.send_request" : "agent.send_now"))}</button>`
|
|
233
236
|
: mode === "resume"
|
|
234
|
-
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
237
|
+
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
238
|
+
? "agent.restoring"
|
|
239
|
+
: options.conversation && inputMode === "terminal" ? "agent.send_terminal" : "agent.restore_and_send"))}</button>`
|
|
235
240
|
: mode === "handoff"
|
|
236
|
-
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
241
|
+
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
242
|
+
? "agent.handing_off"
|
|
243
|
+
: options.conversation && inputMode === "terminal" ? "agent.send_terminal" : "agent.handoff_and_send"))}</button>`
|
|
237
244
|
: mode === "origin-resume"
|
|
238
|
-
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
245
|
+
? `<button type="submit" ${canSend ? "" : "disabled"}>${esc(t(sending
|
|
246
|
+
? "agent.connecting"
|
|
247
|
+
: options.conversation && inputMode === "terminal" ? "agent.send_terminal" : "agent.background_and_send"))}</button>`
|
|
239
248
|
: mode === "connect"
|
|
240
249
|
? `<button type="button" data-agent-bridge-copy="${esc(targetSession.provider)}">${esc(t("agent.copy_bridge"))}</button>`
|
|
241
250
|
: "";
|
|
242
251
|
const editable = relayed || ["direct", "resume", "handoff", "origin-resume"].includes(mode);
|
|
243
|
-
const placeholder = editable
|
|
252
|
+
const placeholder = editable
|
|
253
|
+
? t(options.conversation
|
|
254
|
+
? inputMode === "terminal" ? "agent.terminal_placeholder" : "agent.conversation_placeholder"
|
|
255
|
+
: "agent.command_example")
|
|
256
|
+
: status;
|
|
244
257
|
const availabilityClass = mode === "direct" ? "connected" : ["resume", "handoff", "origin-resume"].includes(mode) ? "resume-ready" : "unavailable";
|
|
245
|
-
|
|
246
|
-
|
|
258
|
+
const terminalAction = mode === "direct"
|
|
259
|
+
? `<button type="button" data-agent-terminal-open="${esc(session.id)}" ${target ? "" : "disabled"}>${esc(t("agent.open_terminal"))}</button>`
|
|
260
|
+
: ["resume", "handoff", "origin-resume"].includes(mode)
|
|
261
|
+
? `<button type="button" data-resume-agent="${esc(session.id)}">${esc(t("agent.reconnect_terminal"))}</button>`
|
|
262
|
+
: "";
|
|
263
|
+
const terminalDetail = mode === "direct"
|
|
264
|
+
? t("agent.terminal_session_active", { target: target?.label || t("agent.choose_terminal") })
|
|
265
|
+
: mode === "ended" ? t("agent.terminal_unavailable") : controlHelp;
|
|
266
|
+
const terminalToggle = options.conversation && inputMode !== "terminal"
|
|
267
|
+
? `<button class="conversation-terminal-toggle" type="button" data-agent-command-session="${esc(session.id)}"
|
|
268
|
+
data-agent-command-input-mode="terminal" aria-expanded="false" ${editable ? "" : "disabled"}>
|
|
269
|
+
<span aria-hidden="true">›_</span>${esc(t("agent.input_mode_terminal"))}
|
|
270
|
+
</button>`
|
|
271
|
+
: "";
|
|
272
|
+
const terminalExpanded = options.conversation && inputMode === "terminal"
|
|
273
|
+
? `<div class="conversation-terminal-expanded ${mode === "direct" ? "connected" : ""}">
|
|
274
|
+
<button type="button" data-agent-command-session="${esc(session.id)}" data-agent-command-input-mode="conversation">
|
|
275
|
+
<span aria-hidden="true">←</span>${esc(t("agent.return_to_ai_input"))}
|
|
276
|
+
</button>
|
|
277
|
+
<span><b>${esc(t("agent.terminal_direct_input"))}</b><small>${esc(terminalDetail)}</small></span>
|
|
278
|
+
${terminalAction}
|
|
279
|
+
</div>`
|
|
280
|
+
: "";
|
|
281
|
+
const form = `<form class="agent-command-panel ${availabilityClass} control-${mode} ${options.conversation ? "conversation-composer" : ""}"
|
|
282
|
+
data-agent-command-form="${esc(session.id)}" data-agent-command-route-selected="${esc(route)}"
|
|
283
|
+
data-agent-command-input-mode-selected="${esc(inputMode)}" data-agent-command-routing="${options.conversation ? "conversation" : "session"}">
|
|
247
284
|
<header>
|
|
248
285
|
<span class="agent-command-icon" aria-hidden="true">›_</span>
|
|
249
286
|
<span><b>${esc(t(options.conversation ? "agent.conversation_title" : "agent.command_title"))}</b><small>${esc(status)}</small></span>
|
|
250
287
|
<i class="${mode === "direct" ? "connected" : ""}" aria-hidden="true"></i>
|
|
251
288
|
</header>
|
|
252
|
-
${routePicker}
|
|
253
289
|
${picker}
|
|
254
290
|
<label class="agent-command-input">
|
|
255
291
|
<span class="sr-only">${esc(t("agent.command_sr"))}</span>
|
|
256
292
|
<textarea data-agent-command-draft="${esc(session.id)}" maxlength="8000" rows="${options.conversation ? "2" : "3"}"
|
|
257
293
|
placeholder="${esc(placeholder)}" ${editable ? "" : "disabled"}>${editable ? esc(draft) : ""}</textarea>
|
|
258
294
|
</label>
|
|
259
|
-
<div class="agent-command-actions"><small aria-live="polite">${esc(help)}</small>${actions}</div>
|
|
295
|
+
<div class="agent-command-actions"><small aria-live="polite">${esc(help)}</small>${terminalToggle}${actions}</div>
|
|
260
296
|
</form>`;
|
|
297
|
+
return options.conversation
|
|
298
|
+
? `<div class="conversation-composer-shell mode-${esc(inputMode)}">${terminalExpanded}${form}</div>`
|
|
299
|
+
: form;
|
|
261
300
|
}
|
|
262
301
|
|
|
263
302
|
function selectedSession() {
|
|
@@ -304,8 +343,10 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
304
343
|
if (state.agentCommandSending.has(sessionId)) return;
|
|
305
344
|
const session = snapshotSession(sessionId);
|
|
306
345
|
if (!session || !window.LoadToAgentTerminal) return context.toast(t("agent.latest_not_found"));
|
|
307
|
-
const
|
|
308
|
-
const
|
|
346
|
+
const drawerSubmission = form?.dataset.agentCommandRouting === "conversation";
|
|
347
|
+
const inputMode = drawerSubmission ? form?.dataset.agentCommandInputModeSelected || "conversation" : "terminal";
|
|
348
|
+
const conversationSubmission = drawerSubmission && inputMode === "conversation";
|
|
349
|
+
const routingEnabled = drawerSubmission && Boolean(session.parentId);
|
|
309
350
|
const requestedRoute = routingEnabled ? form?.dataset.agentCommandRouteSelected || selectedAgentCommandRoute(session) : "direct";
|
|
310
351
|
const routeContext = routingEnabled
|
|
311
352
|
? routedAgentCommandContext(session, requestedRoute)
|
|
@@ -315,7 +356,7 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
315
356
|
if (routingEnabled && !["direct", "resume", "handoff", "origin-resume"].includes(mode)) return context.toast(t("agent.route_unavailable"));
|
|
316
357
|
const input = form.querySelector("[data-agent-command-draft]");
|
|
317
358
|
const command = String((input && input.value) || "").trim();
|
|
318
|
-
const routedCommand = routingEnabled && routeContext.route === "parent"
|
|
359
|
+
const routedCommand = conversationSubmission && routingEnabled && routeContext.route === "parent"
|
|
319
360
|
? t("agent.route_via_parent_prompt", {
|
|
320
361
|
task: session.delegation?.taskName || session.taskName || session.agentName || session.title,
|
|
321
362
|
message: command,
|
|
@@ -324,15 +365,17 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
324
365
|
if (mode === "resume" || mode === "handoff" || mode === "origin-resume") {
|
|
325
366
|
if (input) state.agentCommandDrafts.set(sessionId, input.value);
|
|
326
367
|
if (!command) return context.toast(t("agent.enter_command"));
|
|
327
|
-
if (
|
|
368
|
+
if (drawerSubmission) {
|
|
328
369
|
state.agentCommandSending.add(sessionId);
|
|
329
|
-
const pendingMessage = beginConversationMessage(session, command);
|
|
370
|
+
const pendingMessage = conversationSubmission ? beginConversationMessage(session, command) : null;
|
|
330
371
|
try {
|
|
331
372
|
await window.LoadToAgentTerminal.resumeForAgent(targetSession, routedCommand, true, { focus: false });
|
|
332
373
|
state.agentCommandDrafts.delete(sessionId);
|
|
333
374
|
if (input) input.value = "";
|
|
334
375
|
updateConversationMessage(sessionId, pendingMessage, "awaiting");
|
|
335
|
-
context.toast(t(routingEnabled && routeContext.route === "parent"
|
|
376
|
+
context.toast(t(routingEnabled && routeContext.route === "parent"
|
|
377
|
+
? "agent.command_routed_via_parent"
|
|
378
|
+
: inputMode === "terminal" ? "agent.terminal_command_sent_background" : "agent.command_sent_background"));
|
|
336
379
|
} catch (error) {
|
|
337
380
|
updateConversationMessage(sessionId, pendingMessage, "failed", errorText(error, "agent.send_failed"));
|
|
338
381
|
context.toast(errorText(error, "agent.send_failed"));
|
|
@@ -364,16 +407,18 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
364
407
|
state.agentCommandDrafts.delete(sessionId);
|
|
365
408
|
if (input) input.value = "";
|
|
366
409
|
updateConversationMessage(sessionId, pendingMessage, "awaiting");
|
|
367
|
-
context.toast(t(routingEnabled && routeContext.route === "parent"
|
|
410
|
+
context.toast(t(routingEnabled && routeContext.route === "parent"
|
|
411
|
+
? "agent.command_routed_via_parent"
|
|
412
|
+
: inputMode === "terminal" ? "agent.terminal_command_sent" : "agent.command_sent", { target: target.label }));
|
|
368
413
|
} catch (error) {
|
|
369
414
|
const latest = snapshotSession(targetSession.id) || targetSession;
|
|
370
415
|
const support = agentResumeSupport(latest);
|
|
371
416
|
const shouldRecover = support.supported
|
|
372
|
-
&& (
|
|
417
|
+
&& (drawerSubmission || !agentCommandTargets(latest).length);
|
|
373
418
|
if (shouldRecover) {
|
|
374
419
|
try {
|
|
375
420
|
state.agentCommandDrafts.set(sessionId, command);
|
|
376
|
-
await window.LoadToAgentTerminal.resumeForAgent(latest, routedCommand, true, { focus:
|
|
421
|
+
await window.LoadToAgentTerminal.resumeForAgent(latest, routedCommand, true, { focus: drawerSubmission ? false : true });
|
|
377
422
|
state.agentCommandDrafts.delete(sessionId);
|
|
378
423
|
if (input) input.value = "";
|
|
379
424
|
updateConversationMessage(sessionId, pendingMessage, "awaiting");
|
|
@@ -389,10 +434,12 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
389
434
|
}
|
|
390
435
|
} finally {
|
|
391
436
|
state.agentCommandSending.delete(sessionId);
|
|
392
|
-
if (
|
|
437
|
+
if (drawerSubmission) context.renderDrawer?.();
|
|
393
438
|
if (submit && submit.isConnected) {
|
|
394
439
|
submit.disabled = false;
|
|
395
|
-
submit.textContent = t(routingEnabled && routeContext.route === "parent"
|
|
440
|
+
submit.textContent = t(routingEnabled && routeContext.route === "parent"
|
|
441
|
+
? "agent.send_via_parent"
|
|
442
|
+
: inputMode === "terminal" ? "agent.send_terminal" : drawerSubmission ? "agent.send_request" : "agent.send_now");
|
|
396
443
|
}
|
|
397
444
|
}
|
|
398
445
|
}
|
|
@@ -404,6 +451,7 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
|
|
|
404
451
|
const target = chosenAgentCommandTarget(session, routeContext.route);
|
|
405
452
|
if (!target)
|
|
406
453
|
return context.toast(t(routeContext.targets.length ? "agent.select_open_target" : "agent.no_writable_terminal"));
|
|
454
|
+
if ($("#detailDrawer").classList.contains("open")) context.closeDrawer?.(false);
|
|
407
455
|
selectView(target.kind === "tmux" ? "tmux" : "terminal");
|
|
408
456
|
try {
|
|
409
457
|
await window.LoadToAgentTerminal.openForAgent(routeContext.targetSession, target.id, state.agentCommandDrafts.get(sessionId) || "");
|
|
@@ -145,16 +145,53 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
|
|
|
145
145
|
return !isProjectlessSession(session) && projectContainsPath(state.workspace, sessionOriginPath(session));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
function unlinkedLiveTmuxSessions() {
|
|
149
|
+
const displayedSessionIds = new Set(displaySessions().map((session) => String(session.id || "")));
|
|
150
|
+
const allSessionsById = new Map((state.snapshot?.sessions || []).map((session) => [String(session.id || ""), session]));
|
|
151
|
+
const sessions = [];
|
|
152
|
+
for (const distro of state.snapshot?.tmux?.distros || []) {
|
|
153
|
+
for (const tmuxSession of distro.sessions || []) {
|
|
154
|
+
for (const window of tmuxSession.windows || []) {
|
|
155
|
+
for (const pane of window.panes || []) {
|
|
156
|
+
if (!pane.agent || pane.dead || !pane.cwd || !isProviderVisible(pane.agent.provider)) continue;
|
|
157
|
+
const linkedSessionId = String(pane.agent.linkedSessionId || "");
|
|
158
|
+
const linkedSession = linkedSessionId ? allSessionsById.get(linkedSessionId) : null;
|
|
159
|
+
if (linkedSessionId && displayedSessionIds.has(linkedSessionId)) continue;
|
|
160
|
+
// A tmux process can remain alive for days after its linked AI task
|
|
161
|
+
// becomes idle. Do not promote that stale shell back into the Home
|
|
162
|
+
// project count merely because the old conversation aged out of the
|
|
163
|
+
// recent-session list. A still-running linked task remains eligible.
|
|
164
|
+
if (linkedSession && !isControlRoomSession(linkedSession)) continue;
|
|
165
|
+
sessions.push({
|
|
166
|
+
id: `tmux:${pane.id}`,
|
|
167
|
+
provider: pane.agent.provider,
|
|
168
|
+
status: "running",
|
|
169
|
+
originCwd: pane.cwd,
|
|
170
|
+
cwd: pane.cwd,
|
|
171
|
+
workspace: tmuxSession.name,
|
|
172
|
+
title: pane.agent.title || tmuxSession.name,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return sessions;
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
function renderWorkspaces() {
|
|
149
182
|
const rootSessions = displaySessions().filter((session) => !session.parentId);
|
|
150
183
|
const liveRootSessions = rootSessions.filter(isControlRoomSession);
|
|
184
|
+
const tmuxRootSessions = unlinkedLiveTmuxSessions();
|
|
185
|
+
const allLiveRootSessions = [...liveRootSessions, ...tmuxRootSessions];
|
|
151
186
|
const projects = observedProjects(rootSessions);
|
|
152
|
-
const liveProjects = observedProjects(
|
|
187
|
+
const liveProjects = observedProjects(allLiveRootSessions)
|
|
188
|
+
.filter((project) => Number(project.count || 0) > 0);
|
|
153
189
|
const projectlessCount = rootSessions.filter(isProjectlessSession).length;
|
|
154
190
|
const liveProjectlessCount = liveRootSessions.filter(isProjectlessSession).length;
|
|
155
191
|
const savedWorkspaceExists = state.workspace === "all"
|
|
156
192
|
|| (state.workspace === PROJECTLESS_WORKSPACE && projectlessCount > 0)
|
|
157
|
-
|| projects.some((project) => normalizedProjectPath(project.path) === normalizedProjectPath(state.workspace))
|
|
193
|
+
|| projects.some((project) => normalizedProjectPath(project.path) === normalizedProjectPath(state.workspace))
|
|
194
|
+
|| liveProjects.some((project) => normalizedProjectPath(project.path) === normalizedProjectPath(state.workspace));
|
|
158
195
|
if (!savedWorkspaceExists) state.workspace = "all";
|
|
159
196
|
const projectButton = (item, compactClass = "") => `<button type="button" class="workspace-item observed-project ${compactClass} ${Number(item.liveCount || 0) ? "has-live-sessions" : ""} ${state.workspace === item.path ? "selected" : ""}"
|
|
160
197
|
data-workspace="${esc(item.path)}" title="${esc(item.path)}"
|
|
@@ -188,7 +225,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
|
|
|
188
225
|
const desktopHtml =
|
|
189
226
|
`<button type="button" class="workspace-item control-room-project-chip ${state.workspace === "all" ? "selected" : ""}"
|
|
190
227
|
data-workspace="all" aria-pressed="${state.workspace === "all" ? "true" : "false"}">
|
|
191
|
-
<strong>${esc(t("control.all_projects"))}</strong><small>${
|
|
228
|
+
<strong>${esc(t("control.all_projects"))}</strong><small>${allLiveRootSessions.length}</small>
|
|
192
229
|
</button>` +
|
|
193
230
|
liveProjects.map((item) => projectButton(item, "control-room-project-chip")).join("") +
|
|
194
231
|
(liveProjectlessCount
|
|
@@ -636,6 +673,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
|
|
|
636
673
|
sessionWorkspaceLabel,
|
|
637
674
|
controlRoomProject,
|
|
638
675
|
matchesWorkspaceFilter,
|
|
676
|
+
unlinkedLiveTmuxSessions,
|
|
639
677
|
renderWorkspaces,
|
|
640
678
|
renderGlobalStats,
|
|
641
679
|
formatBytes,
|
|
@@ -55,8 +55,11 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
|
|
|
55
55
|
const label = assistant ? options.assistantLabel : options.userLabel;
|
|
56
56
|
const avatar = assistant ? providerInfo(session.provider).mark : "ME";
|
|
57
57
|
const fullTime = new Date(message.timestamp).toLocaleString(uiLocale());
|
|
58
|
+
const workingIndicator = options.live
|
|
59
|
+
? `<span class="chat-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>`
|
|
60
|
+
: "";
|
|
58
61
|
const answerKind = assistant && options.answerKind
|
|
59
|
-
? `<span class="chat-answer-kind${options.live ? " is-live" : ""}">${esc(options.answerKind)}</span>`
|
|
62
|
+
? `<span class="chat-answer-kind${options.live ? " is-live" : ""}">${esc(options.answerKind)}${workingIndicator}</span>`
|
|
60
63
|
: "";
|
|
61
64
|
const deliveryStatusKey = {
|
|
62
65
|
sending: "drawer.message_sending",
|