loadtoagent 1.3.9 → 1.3.11

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 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
- - tmux 터미널 관리를 사용할 때만 tmux 필요
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 전용 PTY를 조작합니다. AI 카드에서 `터미널에서 열기`를 누르면 새 셸을 만들지 않고 정확히 연결된 기존 터미널을 열며, 왼쪽에는 해당 세션의 이전 대화가 계속 표시됩니다. 터미널 탭을 바꿔도 PTY 출력은 지워지지 않고, 최신 대화 기록도 자동으로 갱신됩니다. 다른 곳에서 임의로 시작한 세션은 계속 볼 수 있지만, 원래 앱이 지원하는 연결 방식이 없으면 보기 전용으로 유지됩니다.
133
+ 이제 외부 터미널과 LoadToAgent 대시보드가 같은 LoadToAgent 전용 세션을 조작합니다. AI 카드에서 `터미널에서 열기`를 누르면 새 셸을 만들지 않고 정확히 연결된 기존 터미널을 열며, 왼쪽에는 해당 세션의 이전 대화가 계속 표시됩니다. 터미널 탭을 바꿔도 출력은 지워지지 않고, 최신 대화 기록도 자동으로 갱신됩니다. 다른 곳에서 임의로 시작한 세션은 계속 볼 수 있지만, 원래 앱이 지원하는 연결 방식이 없으면 보기 전용으로 유지됩니다.
134
134
 
135
- LoadToAgent가 터미널은 실행 중·유휴·자연 종료·시작 실패 상태와 관계없이 사용자가 `세션 종료`를 누를 때까지 세션 터미널 목록과 출력이 유지됩니다. 실행 중인 터미널이 하나라도 있을 창의 `X`를 누르면 시스템 트레이로 숨습니다. 트레이에서 `프로그램 끝내기`를 선택해 대시보드를 완전히 종료해도, 별도의 인증된 로컬 터미널 호스트가 살아 있는 PTY를 계속 유지합니다. 다음 실행에서는 같은 세션 ID·프로세스·출력에 다시 연결됩니다. 사용자가 `세션 종료`를 누르거나 터미널이 자연 종료되거나 운영체제 세션·터미널 호스트 자체가 끝날 때만 실행 중인 터미널이 종료됩니다.
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 only if you want the optional tmux workspace map
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 PTY. Opening a terminal from an AI card reuses the exact connected terminal instead of creating a new shell, keeps its PTY 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.
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
- A terminal opened by LoadToAgent remains in the session-terminal list, with its output intact, whether it is running, idle, naturally exited, or failed to start. When any terminal is active, closing the window with `X` hides LoadToAgent in the system tray. An explicit `Quit app` closes the dashboard while a separate authenticated local terminal host keeps live PTYs running; the next launch reconnects to the same session ID, process, and output. A live terminal ends only when you choose `Close session`, the terminal exits naturally, or the operating-system session/terminal host itself stops.
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
- - 只有使用 tmux 工作区地图时才需要安装 tmux
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 专用 PTY。在其他地方启动的现有会话仍然可见,但除非原应用提供受支持的交接方式,否则会保持只读。
131
+ 外部终端与 LoadToAgent 仪表盘会共同控制同一个 LoadToAgent 专用会话。在 AI 卡片中打开终端时,会复用准确的现有终端和会话 ID,不会创建新的 Shell;切换页面时输出也会保留。在其他地方启动的现有会话仍然可见,但除非原应用提供受支持的交接方式,否则会保持只读。
132
132
 
133
- LoadToAgent 打开的终端无论处于运行、空闲、自然退出或启动失败状态,都会连同输出保留在会话终端列表中。只要有终端仍在运行,点击窗口的 `X` 就会把 LoadToAgent 隐藏到系统托盘。即使从托盘明确选择“退出应用”来关闭仪表盘,独立且经过认证的本地终端主机仍会继续维护活动 PTY;下次启动会重新连接到相同的会话 ID、进程和输出。只有选择“关闭会话”、终端自然退出,或操作系统会话/终端主机本身停止时,活动终端才会结束。
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
@@ -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' && (message.status === 'exited' || message.status === 'failed')) exitCode = Number(message.exitCode || 0);
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;
@@ -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 && (session.status === 'running' || session.status === 'starting'));
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.9",
3
+ "version": "1.3.11",
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",
@@ -124,11 +125,17 @@
124
125
  "target": [
125
126
  {
126
127
  "target": "dmg",
127
- "arch": ["arm64", "x64"]
128
+ "arch": [
129
+ "arm64",
130
+ "x64"
131
+ ]
128
132
  },
129
133
  {
130
134
  "target": "zip",
131
- "arch": ["arm64", "x64"]
135
+ "arch": [
136
+ "arm64",
137
+ "x64"
138
+ ]
132
139
  }
133
140
  ],
134
141
  "artifactName": "LoadToAgent-${version}-${arch}.${ext}"
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
- : relayTarget?.id || "";
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 help = relayed
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 routePicker = routingEnabled
209
- ? `<div class="agent-command-route" role="group" aria-label="${esc(t("agent.route_label"))}">
210
- <span>${esc(t("agent.route_label"))}</span>
211
- <div>${routeContext.options.map(option => `<button type="button" data-agent-command-session="${esc(session.id)}" data-agent-command-route="${esc(option.id)}"
212
- aria-pressed="${option.id === route ? "true" : "false"}" ${option.available ? "" : "disabled"}>${esc(option.label)}</button>`).join("")}</div>
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 ? "agent.sending" : "agent.send_via_parent"))}</button>`
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 ? "agent.sending" : "agent.send_now"))}</button>`
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 ? "agent.restoring" : "agent.restore_and_send"))}</button>`
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 ? "agent.handing_off" : "agent.handoff_and_send"))}</button>`
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 ? "agent.connecting" : "agent.background_and_send"))}</button>`
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 ? t(options.conversation ? "agent.conversation_placeholder" : "agent.command_example") : status;
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
- return `<form class="agent-command-panel ${availabilityClass} control-${mode} ${options.conversation ? "conversation-composer" : ""}"
246
- data-agent-command-form="${esc(session.id)}" data-agent-command-route-selected="${esc(route)}" data-agent-command-routing="${options.conversation ? "conversation" : "session"}">
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 conversationSubmission = form?.dataset.agentCommandRouting === "conversation";
308
- const routingEnabled = conversationSubmission && Boolean(session.parentId);
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,14 +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 (conversationSubmission) {
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);
374
+ if (input) input.value = "";
333
375
  updateConversationMessage(sessionId, pendingMessage, "awaiting");
334
- context.toast(t(routingEnabled && routeContext.route === "parent" ? "agent.command_routed_via_parent" : "agent.command_sent_background"));
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"));
335
379
  } catch (error) {
336
380
  updateConversationMessage(sessionId, pendingMessage, "failed", errorText(error, "agent.send_failed"));
337
381
  context.toast(errorText(error, "agent.send_failed"));
@@ -363,16 +407,18 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
363
407
  state.agentCommandDrafts.delete(sessionId);
364
408
  if (input) input.value = "";
365
409
  updateConversationMessage(sessionId, pendingMessage, "awaiting");
366
- context.toast(t(routingEnabled && routeContext.route === "parent" ? "agent.command_routed_via_parent" : "agent.command_sent", { target: target.label }));
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 }));
367
413
  } catch (error) {
368
414
  const latest = snapshotSession(targetSession.id) || targetSession;
369
415
  const support = agentResumeSupport(latest);
370
416
  const shouldRecover = support.supported
371
- && (conversationSubmission || !agentCommandTargets(latest).length);
417
+ && (drawerSubmission || !agentCommandTargets(latest).length);
372
418
  if (shouldRecover) {
373
419
  try {
374
420
  state.agentCommandDrafts.set(sessionId, command);
375
- await window.LoadToAgentTerminal.resumeForAgent(latest, routedCommand, true, { focus: conversationSubmission ? false : true });
421
+ await window.LoadToAgentTerminal.resumeForAgent(latest, routedCommand, true, { focus: drawerSubmission ? false : true });
376
422
  state.agentCommandDrafts.delete(sessionId);
377
423
  if (input) input.value = "";
378
424
  updateConversationMessage(sessionId, pendingMessage, "awaiting");
@@ -388,10 +434,12 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
388
434
  }
389
435
  } finally {
390
436
  state.agentCommandSending.delete(sessionId);
391
- if (conversationSubmission) context.renderDrawer?.();
437
+ if (drawerSubmission) context.renderDrawer?.();
392
438
  if (submit && submit.isConnected) {
393
439
  submit.disabled = false;
394
- submit.textContent = t(routingEnabled && routeContext.route === "parent" ? "agent.send_via_parent" : "agent.send_now");
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");
395
443
  }
396
444
  }
397
445
  }
@@ -403,6 +451,7 @@ window.LoadToAgentAppFactories.createAgentActions = function createAgentActions(
403
451
  const target = chosenAgentCommandTarget(session, routeContext.route);
404
452
  if (!target)
405
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);
406
455
  selectView(target.kind === "tmux" ? "tmux" : "terminal");
407
456
  try {
408
457
  await window.LoadToAgentTerminal.openForAgent(routeContext.targetSession, target.id, state.agentCommandDrafts.get(sessionId) || "");
@@ -211,7 +211,9 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
211
211
  if (desktopList.dataset.overflowBound !== "true") {
212
212
  desktopList.dataset.overflowBound = "true";
213
213
  desktopList.addEventListener("scroll", updateProjectOverflow, { passive: true });
214
- desktopList._overflowObserver = new ResizeObserver(updateProjectOverflow);
214
+ // Mutating layout-related classes directly inside ResizeObserver can
215
+ // trigger Chromium's "undelivered notifications" loop warning.
216
+ desktopList._overflowObserver = new ResizeObserver(() => requestAnimationFrame(updateProjectOverflow));
215
217
  desktopList._overflowObserver.observe(desktopList);
216
218
  }
217
219
  requestAnimationFrame(updateProjectOverflow);