agentquad 0.4.1 → 0.4.4
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/dist-web/assets/{index-Wl5vjZ8T.css → index-CEiuiF0m.css} +1 -1
- package/dist-web/assets/{index-HC0Fs5po.js → index-DuZ_lMdf.js} +248 -233
- package/dist-web/index.html +2 -2
- package/package.json +1 -1
- package/src/agent-installer-dispatcher.js +87 -0
- package/src/agent-installer-shared.js +101 -0
- package/src/claude-agent-installer.js +135 -0
- package/src/claude-transcript.js +50 -0
- package/src/cli.js +208 -0
- package/src/codex-agent-installer.js +165 -0
- package/src/config.js +6 -0
- package/src/cursor-agent-installer.js +128 -0
- package/src/lark-api-client.js +59 -4
- package/src/lark-bot.js +7 -7
- package/src/lark-post.js +285 -0
- package/src/mcp/server.js +34 -21
- package/src/mcp/tools/openclaw/index.js +6 -0
- package/src/openclaw-hook.js +1 -1
- package/src/permission-prompt.js +232 -0
- package/src/pty.js +35 -6
- package/src/routes/ai-terminal.js +166 -14
- package/src/server.js +4 -3
- package/src/templates/agent-skills/agentquad-child.cursor.mdc +26 -0
- package/src/templates/agent-skills/agentquad-child.skill.md +29 -0
package/src/pty.js
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { spawnSync, execFile as execFileCb } from 'node:child_process'
|
|
5
|
-
import { readdirSync, statSync, existsSync, watch as fsWatch, mkdirSync, openSync, readSync, closeSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { readdirSync, statSync, existsSync, unlinkSync, watch as fsWatch, mkdirSync, openSync, readSync, closeSync, readFileSync } from 'node:fs'
|
|
6
6
|
import { delimiter, dirname, isAbsolute, join } from 'node:path'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
8
|
import { createCodexPromptDetector } from './codex-prompt-detector.js'
|
|
@@ -419,7 +419,7 @@ export class PtyManager extends EventEmitter {
|
|
|
419
419
|
* 会话建立时调 create()、收到前端真实 cols/rows 后再调 startWithSize(),
|
|
420
420
|
* 这样 PTY 永远不会在默认 80×24 上 spawn 一次再 resize。
|
|
421
421
|
*/
|
|
422
|
-
create({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv }) {
|
|
422
|
+
create({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv, mcpConfigPath = null, codexMcpUrl = null }) {
|
|
423
423
|
const toolCfg = this.tools[tool]
|
|
424
424
|
if (!toolCfg) throw new Error(`unknown tool: ${tool}`)
|
|
425
425
|
const baseArgs = toolCfg.args || []
|
|
@@ -433,6 +433,18 @@ export class PtyManager extends EventEmitter {
|
|
|
433
433
|
// Claude 支持 --session-id <uuid>:新会话时由我们预生成,避免事后靠 FS/输出扫描。
|
|
434
434
|
const presetClaudeId = tool === 'claude' && !resumeNativeId ? randomUUID() : null
|
|
435
435
|
const claudeSessionArgs = presetClaudeId ? ['--session-id', presetClaudeId] : []
|
|
436
|
+
// AgentQuad runtime MCP config injection
|
|
437
|
+
const mcpConfigArgs = (tool === 'claude' && mcpConfigPath)
|
|
438
|
+
? ['--mcp-config', mcpConfigPath]
|
|
439
|
+
: []
|
|
440
|
+
// Codex 用 --config key=value 直接覆写(不需要文件)
|
|
441
|
+
// 注意 codex 把 value 当 TOML 表达式解析,URL 字符串要带双引号
|
|
442
|
+
const codexMcpArgs = (tool === 'codex' && codexMcpUrl)
|
|
443
|
+
? [
|
|
444
|
+
'-c', `mcp_servers.agentquad.url="${codexMcpUrl}"`,
|
|
445
|
+
'-c', 'mcp_servers.agentquad.transport="http"',
|
|
446
|
+
]
|
|
447
|
+
: []
|
|
436
448
|
|
|
437
449
|
// cursor-agent 没有 --session-id 预置,但有 `cursor-agent create-chat` 异步建会话拿 chatId。
|
|
438
450
|
// 新会话先异步跑 create-chat,拿到 chatId 后在 startWithSize() 里用 --resume 进交互模式。
|
|
@@ -445,17 +457,21 @@ export class PtyManager extends EventEmitter {
|
|
|
445
457
|
|
|
446
458
|
let args
|
|
447
459
|
if (resumeNativeId) {
|
|
448
|
-
if (tool === 'codex') args = [...baseArgs, ...permissionArgs, 'resume', resumeNativeId]
|
|
460
|
+
if (tool === 'codex') args = [...baseArgs, ...permissionArgs, ...codexMcpArgs, 'resume', resumeNativeId]
|
|
449
461
|
else if (tool === 'cursor') args = [...baseArgs, ...permissionArgs, '--resume', resumeNativeId]
|
|
450
|
-
else args = [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, '--resume', resumeNativeId]
|
|
462
|
+
else args = [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...mcpConfigArgs, '--resume', resumeNativeId]
|
|
451
463
|
} else if (tool === 'cursor' && cursorResumeId) {
|
|
452
464
|
args = useCliPrompt
|
|
453
465
|
? [...baseArgs, ...permissionArgs, '--resume', cursorResumeId, prompt]
|
|
454
466
|
: [...baseArgs, ...permissionArgs, '--resume', cursorResumeId]
|
|
455
467
|
} else {
|
|
468
|
+
// 关键:mcpConfigArgs(--mcp-config <FILE>)必须放在某个 --<flag> 之前,
|
|
469
|
+
// 否则 Claude 的变长 --mcp-config 会贪婪吃后面的 prompt 当成 "另一个配置文件"
|
|
470
|
+
// (Claude Code GitHub issue #5593 同一类问题)。
|
|
471
|
+
// 因此把 mcpConfigArgs 放在 claudeSessionArgs (--session-id) 之前。
|
|
456
472
|
args = useCliPrompt
|
|
457
|
-
? [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...claudeSessionArgs, prompt]
|
|
458
|
-
: [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...claudeSessionArgs]
|
|
473
|
+
? [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...mcpConfigArgs, ...claudeSessionArgs, ...codexMcpArgs, prompt]
|
|
474
|
+
: [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...mcpConfigArgs, ...claudeSessionArgs, ...codexMcpArgs]
|
|
459
475
|
}
|
|
460
476
|
let effectiveCwd = cwd || process.env.HOME || process.cwd()
|
|
461
477
|
|
|
@@ -510,6 +526,7 @@ export class PtyManager extends EventEmitter {
|
|
|
510
526
|
detector: null,
|
|
511
527
|
lastTuiAlertAt: 0,
|
|
512
528
|
cursorChatPromise,
|
|
529
|
+
mcpConfigPath: mcpConfigPath || null,
|
|
513
530
|
spawnSpec: {
|
|
514
531
|
args,
|
|
515
532
|
env,
|
|
@@ -572,6 +589,10 @@ export class PtyManager extends EventEmitter {
|
|
|
572
589
|
} catch (error) {
|
|
573
590
|
// ptyFactory failed → session record is stranded (no proc → no onExit to clean it up).
|
|
574
591
|
// Remove it explicitly so callers can retry / start a new session with the same id.
|
|
592
|
+
// Task 10: 防止 ptyFactory 失败时孤立 runtime mcp config 文件
|
|
593
|
+
if (session.mcpConfigPath) {
|
|
594
|
+
try { if (existsSync(session.mcpConfigPath)) unlinkSync(session.mcpConfigPath) } catch { /* ignore */ }
|
|
595
|
+
}
|
|
575
596
|
this.sessions.delete(sessionId)
|
|
576
597
|
error.message = `PTY spawn failed for ${tool} (bin=${toolCfg.bin}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
|
|
577
598
|
throw error
|
|
@@ -846,6 +867,10 @@ export class PtyManager extends EventEmitter {
|
|
|
846
867
|
if (this.sidecar && session.tool === 'codex' && session.nativeId) {
|
|
847
868
|
try { this.sidecar.clear(session.nativeId) } catch { /* ignore */ }
|
|
848
869
|
}
|
|
870
|
+
// Cleanup runtime MCP config file (Task 10)
|
|
871
|
+
if (session.mcpConfigPath) {
|
|
872
|
+
try { if (existsSync(session.mcpConfigPath)) unlinkSync(session.mcpConfigPath) } catch { /* ignore */ }
|
|
873
|
+
}
|
|
849
874
|
const fullLog = session.fullLog.join('')
|
|
850
875
|
this.sessions.delete(sessionId)
|
|
851
876
|
this.emit('done', {
|
|
@@ -979,6 +1004,10 @@ export class PtyManager extends EventEmitter {
|
|
|
979
1004
|
if (s.detectTimer) { try { clearInterval(s.detectTimer) } catch { /* ignore */ } s.detectTimer = null }
|
|
980
1005
|
if (s.cursorWatchTimer) { try { clearInterval(s.cursorWatchTimer) } catch { /* ignore */ } s.cursorWatchTimer = null }
|
|
981
1006
|
if (s.fsWatcher) { try { s.fsWatcher.close() } catch { /* ignore */ } s.fsWatcher = null }
|
|
1007
|
+
// Cleanup runtime MCP config file (Task 10)
|
|
1008
|
+
if (s.mcpConfigPath) {
|
|
1009
|
+
try { if (existsSync(s.mcpConfigPath)) unlinkSync(s.mcpConfigPath) } catch { /* ignore */ }
|
|
1010
|
+
}
|
|
982
1011
|
this.sessions.delete(sessionId)
|
|
983
1012
|
this.emit('done', {
|
|
984
1013
|
sessionId,
|
|
@@ -3,8 +3,12 @@ import { spawnSync } from 'node:child_process'
|
|
|
3
3
|
import { Router } from 'express'
|
|
4
4
|
import { writeFile, mkdir } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
6
7
|
import pidusage from 'pidusage'
|
|
7
8
|
import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
|
|
9
|
+
import { writeRuntimeMcpConfig } from '../agent-installer-shared.js'
|
|
10
|
+
import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt } from '../permission-prompt.js'
|
|
11
|
+
import { findLatestPendingToolUse } from '../claude-transcript.js'
|
|
8
12
|
|
|
9
13
|
const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
|
|
10
14
|
const CLEANUP_MS = 30 * 60_000
|
|
@@ -160,7 +164,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
160
164
|
if (session.status === 'running' && session.awaitingReply === false) return false
|
|
161
165
|
session.status = 'running'
|
|
162
166
|
session.awaitingReply = false
|
|
163
|
-
if (wasPending)
|
|
167
|
+
if (wasPending) {
|
|
168
|
+
session.recentOutput = ''
|
|
169
|
+
session.permissionPrompt = null
|
|
170
|
+
}
|
|
164
171
|
persistLiveSessionState(session, 'running', 'ai_running')
|
|
165
172
|
if (wasPending) broadcastToSession(session, { type: 'pending_cleared' })
|
|
166
173
|
return true
|
|
@@ -182,12 +189,73 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
182
189
|
// 也翻 pending_confirm,会让 session 在 AI 完成回话之后无故卡在"待确认",前端没有任何
|
|
183
190
|
// 入口能把它清掉(focus 只清 unread;markSessionRunningAfterInput 需要真实输入)。
|
|
184
191
|
// 所以 idle / 其它非 running 状态下,直接拒绝翻转。
|
|
185
|
-
|
|
192
|
+
// promptText 可由 caller 显式传入(Codex 的 prompt-detector 已经握有一段干净文本);
|
|
193
|
+
// 不传则从 session.recentOutput 提取尾部(Claude Notification 路径走这条)。
|
|
194
|
+
// 状态已经是 pending_confirm 时也允许更新 pendingPrompt —— 同一轮等待中 PTY
|
|
195
|
+
// 可能继续追加输出(如选项变化、高亮位移),让前端拿到最新文案。
|
|
196
|
+
function markPendingConfirm(sessionId, { source = null, promptText = null } = {}) {
|
|
186
197
|
const session = sessions.get(sessionId)
|
|
187
198
|
if (!session) return false
|
|
188
199
|
if (!LIVE_AI_STATUSES.has(session.status)) return false
|
|
189
|
-
|
|
190
|
-
if (session.status !== 'running') return false
|
|
200
|
+
const wasPending = session.status === 'pending_confirm'
|
|
201
|
+
if (!wasPending && session.status !== 'running') return false
|
|
202
|
+
|
|
203
|
+
let text = ''
|
|
204
|
+
let options = []
|
|
205
|
+
|
|
206
|
+
// Claude 优先走 jsonl 路径:Notification fire 时 jsonl 末尾通常已经写好了
|
|
207
|
+
// pending 的 tool_use 块(Bash 命令、Edit 文件 path 等),结构化、无 ANSI 噪声。
|
|
208
|
+
if (!promptText && session.tool === 'claude' && session.nativeSessionId && pty?.findClaudeSession) {
|
|
209
|
+
try {
|
|
210
|
+
const loc = pty.findClaudeSession(session.nativeSessionId)
|
|
211
|
+
if (loc?.filePath) {
|
|
212
|
+
const toolUse = findLatestPendingToolUse(loc.filePath)
|
|
213
|
+
if (toolUse) {
|
|
214
|
+
text = formatToolUseAsPrompt(toolUse)
|
|
215
|
+
options = CLAUDE_DEFAULT_PERMISSION_OPTIONS
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch { /* ignore — 走 PTY 兜底 */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 兜底:从 PTY 提取(Codex 主路径 / Claude jsonl 拿不到时的 backup)。
|
|
222
|
+
// recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
|
|
223
|
+
// 再兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
|
|
224
|
+
if (!text) {
|
|
225
|
+
const extractSource = promptText || session.recentOutput || ''
|
|
226
|
+
let historicalRaw = null
|
|
227
|
+
if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
|
|
228
|
+
const joined = session.outputHistory.join('')
|
|
229
|
+
historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
|
|
230
|
+
}
|
|
231
|
+
const r = extractPermissionPrompt(extractSource, { historicalRaw })
|
|
232
|
+
text = r.text
|
|
233
|
+
options = r.options
|
|
234
|
+
}
|
|
235
|
+
const hasContent = !!(text || options.length)
|
|
236
|
+
const prevPrompt = session.permissionPrompt || null
|
|
237
|
+
|
|
238
|
+
if (wasPending) {
|
|
239
|
+
// 已经是 pending_confirm:只在 prompt 文本/选项真的有变化时才更新并再广播。
|
|
240
|
+
// 多次 Notification/detector 在同一轮内反复 fire 是常态,不能每次都刷前端。
|
|
241
|
+
const sameText = (prevPrompt?.text || '') === text
|
|
242
|
+
const sameOptions = JSON.stringify(prevPrompt?.options || []) === JSON.stringify(options)
|
|
243
|
+
if (!hasContent || (sameText && sameOptions)) return true
|
|
244
|
+
session.permissionPrompt = { text, options, source: source || 'hook', createdAt: Date.now() }
|
|
245
|
+
broadcastToSession(session, {
|
|
246
|
+
type: 'pending_confirm',
|
|
247
|
+
snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
|
|
248
|
+
promptText: text,
|
|
249
|
+
options,
|
|
250
|
+
source: source || 'hook',
|
|
251
|
+
})
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
session.permissionPrompt = hasContent
|
|
256
|
+
? { text, options, source: source || 'hook', createdAt: Date.now() }
|
|
257
|
+
: null
|
|
258
|
+
|
|
191
259
|
session.status = 'pending_confirm'
|
|
192
260
|
const todo = db.getTodo(session.todoId)
|
|
193
261
|
if (todo) {
|
|
@@ -209,6 +277,8 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
209
277
|
broadcastToSession(session, {
|
|
210
278
|
type: 'pending_confirm',
|
|
211
279
|
snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
|
|
280
|
+
promptText: text,
|
|
281
|
+
options,
|
|
212
282
|
source: source || 'hook',
|
|
213
283
|
})
|
|
214
284
|
return true
|
|
@@ -439,7 +509,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
439
509
|
})
|
|
440
510
|
|
|
441
511
|
// ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
|
|
442
|
-
function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false }) {
|
|
512
|
+
function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null }) {
|
|
443
513
|
if (!todoId || typeof prompt !== 'string' || !tool) {
|
|
444
514
|
const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
|
|
445
515
|
throw err
|
|
@@ -522,6 +592,38 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
522
592
|
QUADTODO_TODO_ID: String(todoId),
|
|
523
593
|
QUADTODO_TODO_TITLE: String(todo.title || ''),
|
|
524
594
|
}
|
|
595
|
+
// Task 10: 嵌套深度 + 父 todo id 注入
|
|
596
|
+
const parentDepthRaw = process.env.QUADTODO_DEPTH
|
|
597
|
+
const parentDepth = parentDepthRaw !== undefined && parentDepthRaw !== '' ? Number(parentDepthRaw) : -1
|
|
598
|
+
autoEnv.QUADTODO_DEPTH = String(parentDepth + 1)
|
|
599
|
+
// parentTodoId 优先来自 MCP 工具显式传入;否则 fallback 到 process.env(适合 PTY 嵌套场景,但 AgentQuad 主进程通常没设)
|
|
600
|
+
autoEnv.QUADTODO_PARENT_TODO_ID = parentTodoId != null
|
|
601
|
+
? String(parentTodoId)
|
|
602
|
+
: String(process.env.QUADTODO_TODO_ID || '')
|
|
603
|
+
// 添加 QUADTODO_URL 让 hook/child agent 知道访问哪个端口
|
|
604
|
+
const cfgPort = cfg?.port || 5677
|
|
605
|
+
autoEnv.QUADTODO_URL = `http://127.0.0.1:${cfgPort}`
|
|
606
|
+
|
|
607
|
+
// Task 10: 运行时 MCP 配置注入(C 方案)— claude 走 --mcp-config <file>
|
|
608
|
+
let runtimeMcpPath = null
|
|
609
|
+
if (tool === 'claude') {
|
|
610
|
+
try {
|
|
611
|
+
const runtimeDir = cfg?.agents?.runtimeDir
|
|
612
|
+
? cfg.agents.runtimeDir.replace(/^~/, homedir())
|
|
613
|
+
: join(homedir(), '.agentquad', 'run')
|
|
614
|
+
const out = writeRuntimeMcpConfig({ runtimeDir, sessionId, port: cfgPort, tool: 'claude' })
|
|
615
|
+
runtimeMcpPath = out.path
|
|
616
|
+
} catch (e) {
|
|
617
|
+
console.warn(`[ai-terminal] runtime mcp config write failed: ${e.message}`)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Task 11: Codex 走 --config <key=value>,不需要文件,只需要构造 URL
|
|
622
|
+
let codexMcpUrl = null
|
|
623
|
+
if (tool === 'codex') {
|
|
624
|
+
codexMcpUrl = `http://127.0.0.1:${cfgPort}/mcp`
|
|
625
|
+
}
|
|
626
|
+
|
|
525
627
|
// 1. 先 pty.create 让 PtyManager 把 presetClaudeId / resumeNativeId 落进 session 记录。
|
|
526
628
|
pty.create({
|
|
527
629
|
sessionId,
|
|
@@ -532,6 +634,8 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
532
634
|
resumeNativeId: resumeNativeId || undefined,
|
|
533
635
|
permissionMode: permissionMode || null,
|
|
534
636
|
extraEnv: { ...(extraEnv || {}), ...autoEnv },
|
|
637
|
+
mcpConfigPath: runtimeMcpPath,
|
|
638
|
+
codexMcpUrl,
|
|
535
639
|
})
|
|
536
640
|
// 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
|
|
537
641
|
// 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
|
|
@@ -657,6 +761,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
657
761
|
lastTurnDoneAt: s.lastTurnDoneAt || null,
|
|
658
762
|
outputBytesTotal: s.outputBytesTotal || 0,
|
|
659
763
|
awaitingReply: !!s.awaitingReply,
|
|
764
|
+
permissionPrompt: s.permissionPrompt || null,
|
|
660
765
|
})
|
|
661
766
|
}
|
|
662
767
|
res.json({ ok: true, sessions: out })
|
|
@@ -771,7 +876,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
771
876
|
|
|
772
877
|
// ─── WebSocket hooks (called from server.js on upgrade) ───
|
|
773
878
|
|
|
774
|
-
function addBrowser(sessionId, ws) {
|
|
879
|
+
function addBrowser(sessionId, ws, { role = 'secondary' } = {}) {
|
|
775
880
|
const session = sessions.get(sessionId)
|
|
776
881
|
if (!session) {
|
|
777
882
|
try {
|
|
@@ -780,7 +885,12 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
780
885
|
} catch { /* ignore */ }
|
|
781
886
|
return
|
|
782
887
|
}
|
|
888
|
+
const effectiveRole = role === 'primary' ? 'primary' : 'secondary'
|
|
889
|
+
if (ws) ws.__quadtodoRole = effectiveRole
|
|
783
890
|
session.browsers.add(ws)
|
|
891
|
+
// 不分 primary / secondary,都回放——否则 reopen SessionFocus 会看到一片空白。
|
|
892
|
+
// 旧顾虑是"窄 cols 时代 scrollback 在宽 viewer 里重排乱码";现在交给 init/resize 后
|
|
893
|
+
// TUI 的 SIGWINCH 自重绘兜底,replay 内容沉到 scrollback 上面、用户可滚回去看。
|
|
784
894
|
if (session.outputHistory.length > 0) {
|
|
785
895
|
ws.send(JSON.stringify({ type: 'replay', chunks: session.outputHistory }))
|
|
786
896
|
}
|
|
@@ -802,18 +912,43 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
802
912
|
// 同一个 session 被多个网页同时打开时(比如你在另一个 tab/window 又开了一遍
|
|
803
913
|
// 同一个 todo),每个 tab 的 xterm fit 出的 cols/rows 都不一样,谁最后发谁
|
|
804
914
|
// 赢就会在两个尺寸之间来回抖,Claude 的 TUI 不停重排、scrollback 全是残影。
|
|
805
|
-
//
|
|
806
|
-
//
|
|
915
|
+
//
|
|
916
|
+
// 聚合策略:
|
|
917
|
+
// - 若有任意 viewer 声明 role='primary'(当前只有 SessionFocus 全屏视图会这么报)
|
|
918
|
+
// → 只用 primary viewer 的尺寸,多个 primary 之间仍取 min 兜底
|
|
919
|
+
// secondary viewer(Dock 卡片、迷你窗等)一律忽略,靠 xterm 软折行自己处理
|
|
920
|
+
// - 没有 primary → 退回历史行为:所有 viewer 的 min 聚合
|
|
807
921
|
function applyAggregatedResize(session) {
|
|
808
922
|
if (!canResizeSession(session)) return
|
|
809
923
|
|
|
810
|
-
let
|
|
811
|
-
let
|
|
924
|
+
let primaryCols = Infinity
|
|
925
|
+
let primaryRows = Infinity
|
|
926
|
+
let hasPrimary = false
|
|
927
|
+
let fallbackCols = Infinity
|
|
928
|
+
let fallbackRows = Infinity
|
|
929
|
+
let hasFallback = false
|
|
812
930
|
for (const b of session.browsers) {
|
|
813
931
|
const sz = b.__quadtodoSize
|
|
814
932
|
if (!sz || !isValidResizeSize(sz.cols, sz.rows)) continue
|
|
815
|
-
if (
|
|
816
|
-
|
|
933
|
+
if (b.__quadtodoRole === 'primary') {
|
|
934
|
+
hasPrimary = true
|
|
935
|
+
if (sz.cols < primaryCols) primaryCols = sz.cols
|
|
936
|
+
if (sz.rows < primaryRows) primaryRows = sz.rows
|
|
937
|
+
} else {
|
|
938
|
+
hasFallback = true
|
|
939
|
+
if (sz.cols < fallbackCols) fallbackCols = sz.cols
|
|
940
|
+
if (sz.rows < fallbackRows) fallbackRows = sz.rows
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
let cols, rows
|
|
944
|
+
if (hasPrimary) {
|
|
945
|
+
cols = primaryCols
|
|
946
|
+
rows = primaryRows
|
|
947
|
+
} else if (hasFallback) {
|
|
948
|
+
cols = fallbackCols
|
|
949
|
+
rows = fallbackRows
|
|
950
|
+
} else {
|
|
951
|
+
return
|
|
817
952
|
}
|
|
818
953
|
if (!isValidResizeSize(cols, rows)) return
|
|
819
954
|
cols = clampPtyCols(cols)
|
|
@@ -823,10 +958,17 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
823
958
|
pty.resize(session.sessionId, cols, rows)
|
|
824
959
|
}
|
|
825
960
|
|
|
826
|
-
// \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D
|
|
961
|
+
// \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D 以及裸 ESC(\x1b 或 \x1b\x1b)——
|
|
962
|
+
// 这些才会真正让 Claude/Codex 的 confirm 提示推进。前端"拒绝"按钮发的就是裸 ESC,
|
|
963
|
+
// 不在这里命中的话 markSessionRunningAfterInput 不跑、permissionPrompt 永远不清,
|
|
964
|
+
// 卡片就一直显示,按钮 disabled,看起来像 UI 卡住。
|
|
965
|
+
// 注意:箭头键 '\x1b[A' / 焦点序列 '\x1b[I' 等 ANSI 序列不能命中——所以只匹配
|
|
966
|
+
// "正好等于裸 ESC",与 isInterruptInput 同样的判定。
|
|
827
967
|
function isPendingClearingInput(data) {
|
|
828
968
|
if (typeof data !== 'string' || !data) return false
|
|
829
|
-
|
|
969
|
+
if (/[\r\n\x03\x04]/.test(data)) return true
|
|
970
|
+
if (data === '\x1b' || data === '\x1b\x1b') return true
|
|
971
|
+
return false
|
|
830
972
|
}
|
|
831
973
|
|
|
832
974
|
// 用户希望"打断当前轮"的按键:Ctrl+C(\x03)或 Esc(裸 \x1b / \x1b\x1b)。
|
|
@@ -993,6 +1135,11 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
993
1135
|
const session = sessions.get(sessionId)
|
|
994
1136
|
if (!session) return
|
|
995
1137
|
if (!isValidResizeSize(cols, rows)) return
|
|
1138
|
+
// role 字段可选:addBrowser 时已经按 WS URL ?role= 设过 ws.__quadtodoRole;
|
|
1139
|
+
// 这里只在 init 显式带 role 时才覆盖(保留首次"primary"声明不被无 role 的 init 静默打回)
|
|
1140
|
+
if (ws && (msg.role === 'primary' || msg.role === 'secondary')) {
|
|
1141
|
+
ws.__quadtodoRole = msg.role
|
|
1142
|
+
}
|
|
996
1143
|
if (!session.spawned) {
|
|
997
1144
|
if (session.spawnFallbackTimer) {
|
|
998
1145
|
clearTimeout(session.spawnFallbackTimer)
|
|
@@ -1023,6 +1170,11 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1023
1170
|
const rows = Number(msg.rows)
|
|
1024
1171
|
const session = sessions.get(sessionId)
|
|
1025
1172
|
if (!canResizeSession(session)) return
|
|
1173
|
+
// role 字段可选:只在显式传入时更新,避免普通 resize 把已声明的 primary
|
|
1174
|
+
// 静默打回 secondary
|
|
1175
|
+
if (ws && (msg.role === 'primary' || msg.role === 'secondary')) {
|
|
1176
|
+
ws.__quadtodoRole = msg.role
|
|
1177
|
+
}
|
|
1026
1178
|
if (ws && session.browsers.has(ws)) {
|
|
1027
1179
|
if (!isValidResizeSize(cols, rows)) {
|
|
1028
1180
|
delete ws.__quadtodoSize
|
package/src/server.js
CHANGED
|
@@ -1659,8 +1659,9 @@ export function createServer(opts = {}) {
|
|
|
1659
1659
|
const url = new URL(req.url || "", "http://127.0.0.1");
|
|
1660
1660
|
if (url.pathname.startsWith("/ws/terminal/")) {
|
|
1661
1661
|
const sessionId = url.pathname.replace("/ws/terminal/", "");
|
|
1662
|
+
const role = url.searchParams.get("role") === "primary" ? "primary" : "secondary";
|
|
1662
1663
|
wss.handleUpgrade(req, socket, head, (ws) =>
|
|
1663
|
-
handleBrowserWs(ws, sessionId),
|
|
1664
|
+
handleBrowserWs(ws, sessionId, role),
|
|
1664
1665
|
);
|
|
1665
1666
|
} else {
|
|
1666
1667
|
socket.destroy();
|
|
@@ -1669,8 +1670,8 @@ export function createServer(opts = {}) {
|
|
|
1669
1670
|
|
|
1670
1671
|
const HEARTBEAT_MS = 15_000;
|
|
1671
1672
|
|
|
1672
|
-
function handleBrowserWs(ws, sessionId) {
|
|
1673
|
-
ait.addBrowser(sessionId, ws);
|
|
1673
|
+
function handleBrowserWs(ws, sessionId, role) {
|
|
1674
|
+
ait.addBrowser(sessionId, ws, { role });
|
|
1674
1675
|
|
|
1675
1676
|
ws.on("message", (raw) => {
|
|
1676
1677
|
try {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Use when the user mentions quadtodo / agentquad / 四象限 todo or wants to split a task into sub-tasks delegated to AI agents.
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AgentQuad 子任务委派
|
|
7
|
+
|
|
8
|
+
## 你身处的环境
|
|
9
|
+
- 你正在通过 Cursor 操作一个 AgentQuad(本地四象限 AI 任务调度器)项目
|
|
10
|
+
- AgentQuad 的 MCP 服务在 `http://127.0.0.1:<port>/mcp`(已通过 mcp 连接)
|
|
11
|
+
- 当前 Cursor 会话本身不是 AgentQuad 启动的,所以没有 `QUADTODO_*` 环境变量
|
|
12
|
+
|
|
13
|
+
## 何时触发本 rule
|
|
14
|
+
- 用户说"在 AgentQuad 加一条 todo / 拆出去给另一个 agent / 看下我的四象限"
|
|
15
|
+
- 用户提到 quadtodo / agentquad / 四象限 todo
|
|
16
|
+
|
|
17
|
+
## 操作流程
|
|
18
|
+
1. `list_quadrants` → 决定象限(默认 Q2)
|
|
19
|
+
2. `create_todo(title, quadrant, description, parentId?)` → 拿到 todo id
|
|
20
|
+
3. (可选)`start_ai_session(todoId, tool="claude"|"codex", prompt)` → 把任务交给一个本地 PTY agent
|
|
21
|
+
4. 告诉用户 ticket / id,及它在 AgentQuad web UI(http://127.0.0.1:5677 或 user 配置端口)里可见
|
|
22
|
+
|
|
23
|
+
## 重要约束
|
|
24
|
+
- 创建前先与用户对齐范围与象限
|
|
25
|
+
- `start_ai_session` 默认 `permissionMode=bypass`,子 agent 有写权限,慎重
|
|
26
|
+
- Cursor 无法被 `start_ai_session` 拉起 —— 拉子 agent 只能选 claude / codex
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agentquad-child
|
|
3
|
+
description: |
|
|
4
|
+
Use when the user wants to split the current AgentQuad task into a sub-task and delegate it to another AI agent. Activates inside AgentQuad-launched sessions (env QUADTODO_SESSION_ID present) or when the user explicitly mentions AgentQuad / 四象限 todo.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# AgentQuad 子任务委派
|
|
8
|
+
|
|
9
|
+
## 你身处的环境
|
|
10
|
+
- 你运行在 AgentQuad(本地四象限 AI 任务调度器)里
|
|
11
|
+
- 父任务的 ID 在环境变量 `QUADTODO_TODO_ID`,标题在 `QUADTODO_TODO_TITLE`
|
|
12
|
+
- AgentQuad 的 MCP 服务地址在 `QUADTODO_URL`(已通过 mcp 连接,无需手动配置)
|
|
13
|
+
- `QUADTODO_DEPTH` 表示嵌套层级(0=顶层,1+=被另一个 agent 启动)
|
|
14
|
+
|
|
15
|
+
## 何时触发本 skill
|
|
16
|
+
- 用户说"把 X 拆出去 / 另起一个 agent 干 / 开个分支任务"
|
|
17
|
+
- 你判断当前任务过大、应该拆分
|
|
18
|
+
- 用户主动要求创建/查看/管理 AgentQuad todo
|
|
19
|
+
|
|
20
|
+
## 操作流程
|
|
21
|
+
1. `list_quadrants` → 决定子任务放哪个象限(默认 Q2 重要不紧急)
|
|
22
|
+
2. `create_todo(title, quadrant, parentId=<父 TODO_ID>, description)` → 拿到子 todo id
|
|
23
|
+
3. (可选)`start_ai_session(todoId=<子 id>, parentTodoId=<env QUADTODO_TODO_ID 的值>, tool="claude"|"codex", prompt=<明确任务说明>)`
|
|
24
|
+
4. 把 ticket / 子 id 告诉用户
|
|
25
|
+
|
|
26
|
+
## 重要约束
|
|
27
|
+
- 拆子任务前先**和用户对齐范围**,不要无脑拆
|
|
28
|
+
- 不要为了拆而拆 —— 子任务必须有清晰的、独立可完成的目标
|
|
29
|
+
- `start_ai_session` 默认 `permissionMode=bypass`,子 agent 默认有写权限,慎重
|