agentquad 0.4.1 → 0.4.3
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-nkG0O5n8.js} +244 -229
- 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/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 +188 -0
- package/src/pty.js +35 -6
- package/src/routes/ai-terminal.js +147 -15
- 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,11 @@ 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 { extractPermissionPrompt } from '../permission-prompt.js'
|
|
8
11
|
|
|
9
12
|
const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
|
|
10
13
|
const CLEANUP_MS = 30 * 60_000
|
|
@@ -160,7 +163,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
160
163
|
if (session.status === 'running' && session.awaitingReply === false) return false
|
|
161
164
|
session.status = 'running'
|
|
162
165
|
session.awaitingReply = false
|
|
163
|
-
if (wasPending)
|
|
166
|
+
if (wasPending) {
|
|
167
|
+
session.recentOutput = ''
|
|
168
|
+
session.permissionPrompt = null
|
|
169
|
+
}
|
|
164
170
|
persistLiveSessionState(session, 'running', 'ai_running')
|
|
165
171
|
if (wasPending) broadcastToSession(session, { type: 'pending_cleared' })
|
|
166
172
|
return true
|
|
@@ -182,12 +188,50 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
182
188
|
// 也翻 pending_confirm,会让 session 在 AI 完成回话之后无故卡在"待确认",前端没有任何
|
|
183
189
|
// 入口能把它清掉(focus 只清 unread;markSessionRunningAfterInput 需要真实输入)。
|
|
184
190
|
// 所以 idle / 其它非 running 状态下,直接拒绝翻转。
|
|
185
|
-
|
|
191
|
+
// promptText 可由 caller 显式传入(Codex 的 prompt-detector 已经握有一段干净文本);
|
|
192
|
+
// 不传则从 session.recentOutput 提取尾部(Claude Notification 路径走这条)。
|
|
193
|
+
// 状态已经是 pending_confirm 时也允许更新 pendingPrompt —— 同一轮等待中 PTY
|
|
194
|
+
// 可能继续追加输出(如选项变化、高亮位移),让前端拿到最新文案。
|
|
195
|
+
function markPendingConfirm(sessionId, { source = null, promptText = null } = {}) {
|
|
186
196
|
const session = sessions.get(sessionId)
|
|
187
197
|
if (!session) return false
|
|
188
198
|
if (!LIVE_AI_STATUSES.has(session.status)) return false
|
|
189
|
-
|
|
190
|
-
if (session.status !== 'running') return false
|
|
199
|
+
const wasPending = session.status === 'pending_confirm'
|
|
200
|
+
if (!wasPending && session.status !== 'running') return false
|
|
201
|
+
|
|
202
|
+
const extractSource = promptText || session.recentOutput || ''
|
|
203
|
+
// 主源 recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
|
|
204
|
+
// 兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
|
|
205
|
+
let historicalRaw = null
|
|
206
|
+
if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
|
|
207
|
+
const joined = session.outputHistory.join('')
|
|
208
|
+
historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
|
|
209
|
+
}
|
|
210
|
+
const { text, options } = extractPermissionPrompt(extractSource, { historicalRaw })
|
|
211
|
+
const hasContent = !!(text || options.length)
|
|
212
|
+
const prevPrompt = session.permissionPrompt || null
|
|
213
|
+
|
|
214
|
+
if (wasPending) {
|
|
215
|
+
// 已经是 pending_confirm:只在 prompt 文本/选项真的有变化时才更新并再广播。
|
|
216
|
+
// 多次 Notification/detector 在同一轮内反复 fire 是常态,不能每次都刷前端。
|
|
217
|
+
const sameText = (prevPrompt?.text || '') === text
|
|
218
|
+
const sameOptions = JSON.stringify(prevPrompt?.options || []) === JSON.stringify(options)
|
|
219
|
+
if (!hasContent || (sameText && sameOptions)) return true
|
|
220
|
+
session.permissionPrompt = { text, options, source: source || 'hook', createdAt: Date.now() }
|
|
221
|
+
broadcastToSession(session, {
|
|
222
|
+
type: 'pending_confirm',
|
|
223
|
+
snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
|
|
224
|
+
promptText: text,
|
|
225
|
+
options,
|
|
226
|
+
source: source || 'hook',
|
|
227
|
+
})
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
session.permissionPrompt = hasContent
|
|
232
|
+
? { text, options, source: source || 'hook', createdAt: Date.now() }
|
|
233
|
+
: null
|
|
234
|
+
|
|
191
235
|
session.status = 'pending_confirm'
|
|
192
236
|
const todo = db.getTodo(session.todoId)
|
|
193
237
|
if (todo) {
|
|
@@ -209,6 +253,8 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
209
253
|
broadcastToSession(session, {
|
|
210
254
|
type: 'pending_confirm',
|
|
211
255
|
snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
|
|
256
|
+
promptText: text,
|
|
257
|
+
options,
|
|
212
258
|
source: source || 'hook',
|
|
213
259
|
})
|
|
214
260
|
return true
|
|
@@ -439,7 +485,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
439
485
|
})
|
|
440
486
|
|
|
441
487
|
// ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
|
|
442
|
-
function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false }) {
|
|
488
|
+
function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null }) {
|
|
443
489
|
if (!todoId || typeof prompt !== 'string' || !tool) {
|
|
444
490
|
const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
|
|
445
491
|
throw err
|
|
@@ -522,6 +568,38 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
522
568
|
QUADTODO_TODO_ID: String(todoId),
|
|
523
569
|
QUADTODO_TODO_TITLE: String(todo.title || ''),
|
|
524
570
|
}
|
|
571
|
+
// Task 10: 嵌套深度 + 父 todo id 注入
|
|
572
|
+
const parentDepthRaw = process.env.QUADTODO_DEPTH
|
|
573
|
+
const parentDepth = parentDepthRaw !== undefined && parentDepthRaw !== '' ? Number(parentDepthRaw) : -1
|
|
574
|
+
autoEnv.QUADTODO_DEPTH = String(parentDepth + 1)
|
|
575
|
+
// parentTodoId 优先来自 MCP 工具显式传入;否则 fallback 到 process.env(适合 PTY 嵌套场景,但 AgentQuad 主进程通常没设)
|
|
576
|
+
autoEnv.QUADTODO_PARENT_TODO_ID = parentTodoId != null
|
|
577
|
+
? String(parentTodoId)
|
|
578
|
+
: String(process.env.QUADTODO_TODO_ID || '')
|
|
579
|
+
// 添加 QUADTODO_URL 让 hook/child agent 知道访问哪个端口
|
|
580
|
+
const cfgPort = cfg?.port || 5677
|
|
581
|
+
autoEnv.QUADTODO_URL = `http://127.0.0.1:${cfgPort}`
|
|
582
|
+
|
|
583
|
+
// Task 10: 运行时 MCP 配置注入(C 方案)— claude 走 --mcp-config <file>
|
|
584
|
+
let runtimeMcpPath = null
|
|
585
|
+
if (tool === 'claude') {
|
|
586
|
+
try {
|
|
587
|
+
const runtimeDir = cfg?.agents?.runtimeDir
|
|
588
|
+
? cfg.agents.runtimeDir.replace(/^~/, homedir())
|
|
589
|
+
: join(homedir(), '.agentquad', 'run')
|
|
590
|
+
const out = writeRuntimeMcpConfig({ runtimeDir, sessionId, port: cfgPort, tool: 'claude' })
|
|
591
|
+
runtimeMcpPath = out.path
|
|
592
|
+
} catch (e) {
|
|
593
|
+
console.warn(`[ai-terminal] runtime mcp config write failed: ${e.message}`)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Task 11: Codex 走 --config <key=value>,不需要文件,只需要构造 URL
|
|
598
|
+
let codexMcpUrl = null
|
|
599
|
+
if (tool === 'codex') {
|
|
600
|
+
codexMcpUrl = `http://127.0.0.1:${cfgPort}/mcp`
|
|
601
|
+
}
|
|
602
|
+
|
|
525
603
|
// 1. 先 pty.create 让 PtyManager 把 presetClaudeId / resumeNativeId 落进 session 记录。
|
|
526
604
|
pty.create({
|
|
527
605
|
sessionId,
|
|
@@ -532,6 +610,8 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
532
610
|
resumeNativeId: resumeNativeId || undefined,
|
|
533
611
|
permissionMode: permissionMode || null,
|
|
534
612
|
extraEnv: { ...(extraEnv || {}), ...autoEnv },
|
|
613
|
+
mcpConfigPath: runtimeMcpPath,
|
|
614
|
+
codexMcpUrl,
|
|
535
615
|
})
|
|
536
616
|
// 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
|
|
537
617
|
// 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
|
|
@@ -657,6 +737,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
657
737
|
lastTurnDoneAt: s.lastTurnDoneAt || null,
|
|
658
738
|
outputBytesTotal: s.outputBytesTotal || 0,
|
|
659
739
|
awaitingReply: !!s.awaitingReply,
|
|
740
|
+
permissionPrompt: s.permissionPrompt || null,
|
|
660
741
|
})
|
|
661
742
|
}
|
|
662
743
|
res.json({ ok: true, sessions: out })
|
|
@@ -771,7 +852,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
771
852
|
|
|
772
853
|
// ─── WebSocket hooks (called from server.js on upgrade) ───
|
|
773
854
|
|
|
774
|
-
function addBrowser(sessionId, ws) {
|
|
855
|
+
function addBrowser(sessionId, ws, { role = 'secondary' } = {}) {
|
|
775
856
|
const session = sessions.get(sessionId)
|
|
776
857
|
if (!session) {
|
|
777
858
|
try {
|
|
@@ -780,8 +861,17 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
780
861
|
} catch { /* ignore */ }
|
|
781
862
|
return
|
|
782
863
|
}
|
|
864
|
+
const effectiveRole = role === 'primary' ? 'primary' : 'secondary'
|
|
865
|
+
if (ws) ws.__quadtodoRole = effectiveRole
|
|
783
866
|
session.browsers.add(ws)
|
|
784
|
-
|
|
867
|
+
// primary viewer 进入时:清掉历史 scrollback 并跳过 replay。
|
|
868
|
+
// 旧 scrollback 多半是窄 cols(如默认 80、Dock 卡片宽度)状态下 PTY 写下的硬换行,
|
|
869
|
+
// replay 到全屏视图就是"行间短文字 + 右侧大片空白"的乱码观感。
|
|
870
|
+
// 真正的对话历史在 Conversation tab 由 jsonl 还原,不依赖这里的 PTY scrollback。
|
|
871
|
+
if (effectiveRole === 'primary') {
|
|
872
|
+
session.outputHistory = []
|
|
873
|
+
session.outputSize = 0
|
|
874
|
+
} else if (session.outputHistory.length > 0) {
|
|
785
875
|
ws.send(JSON.stringify({ type: 'replay', chunks: session.outputHistory }))
|
|
786
876
|
}
|
|
787
877
|
ws.send(JSON.stringify({ type: 'auto_mode', autoMode: session.autoMode || null }))
|
|
@@ -802,18 +892,43 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
802
892
|
// 同一个 session 被多个网页同时打开时(比如你在另一个 tab/window 又开了一遍
|
|
803
893
|
// 同一个 todo),每个 tab 的 xterm fit 出的 cols/rows 都不一样,谁最后发谁
|
|
804
894
|
// 赢就会在两个尺寸之间来回抖,Claude 的 TUI 不停重排、scrollback 全是残影。
|
|
805
|
-
//
|
|
806
|
-
//
|
|
895
|
+
//
|
|
896
|
+
// 聚合策略:
|
|
897
|
+
// - 若有任意 viewer 声明 role='primary'(当前只有 SessionFocus 全屏视图会这么报)
|
|
898
|
+
// → 只用 primary viewer 的尺寸,多个 primary 之间仍取 min 兜底
|
|
899
|
+
// secondary viewer(Dock 卡片、迷你窗等)一律忽略,靠 xterm 软折行自己处理
|
|
900
|
+
// - 没有 primary → 退回历史行为:所有 viewer 的 min 聚合
|
|
807
901
|
function applyAggregatedResize(session) {
|
|
808
902
|
if (!canResizeSession(session)) return
|
|
809
903
|
|
|
810
|
-
let
|
|
811
|
-
let
|
|
904
|
+
let primaryCols = Infinity
|
|
905
|
+
let primaryRows = Infinity
|
|
906
|
+
let hasPrimary = false
|
|
907
|
+
let fallbackCols = Infinity
|
|
908
|
+
let fallbackRows = Infinity
|
|
909
|
+
let hasFallback = false
|
|
812
910
|
for (const b of session.browsers) {
|
|
813
911
|
const sz = b.__quadtodoSize
|
|
814
912
|
if (!sz || !isValidResizeSize(sz.cols, sz.rows)) continue
|
|
815
|
-
if (
|
|
816
|
-
|
|
913
|
+
if (b.__quadtodoRole === 'primary') {
|
|
914
|
+
hasPrimary = true
|
|
915
|
+
if (sz.cols < primaryCols) primaryCols = sz.cols
|
|
916
|
+
if (sz.rows < primaryRows) primaryRows = sz.rows
|
|
917
|
+
} else {
|
|
918
|
+
hasFallback = true
|
|
919
|
+
if (sz.cols < fallbackCols) fallbackCols = sz.cols
|
|
920
|
+
if (sz.rows < fallbackRows) fallbackRows = sz.rows
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
let cols, rows
|
|
924
|
+
if (hasPrimary) {
|
|
925
|
+
cols = primaryCols
|
|
926
|
+
rows = primaryRows
|
|
927
|
+
} else if (hasFallback) {
|
|
928
|
+
cols = fallbackCols
|
|
929
|
+
rows = fallbackRows
|
|
930
|
+
} else {
|
|
931
|
+
return
|
|
817
932
|
}
|
|
818
933
|
if (!isValidResizeSize(cols, rows)) return
|
|
819
934
|
cols = clampPtyCols(cols)
|
|
@@ -823,10 +938,17 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
823
938
|
pty.resize(session.sessionId, cols, rows)
|
|
824
939
|
}
|
|
825
940
|
|
|
826
|
-
// \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D
|
|
941
|
+
// \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D 以及裸 ESC(\x1b 或 \x1b\x1b)——
|
|
942
|
+
// 这些才会真正让 Claude/Codex 的 confirm 提示推进。前端"拒绝"按钮发的就是裸 ESC,
|
|
943
|
+
// 不在这里命中的话 markSessionRunningAfterInput 不跑、permissionPrompt 永远不清,
|
|
944
|
+
// 卡片就一直显示,按钮 disabled,看起来像 UI 卡住。
|
|
945
|
+
// 注意:箭头键 '\x1b[A' / 焦点序列 '\x1b[I' 等 ANSI 序列不能命中——所以只匹配
|
|
946
|
+
// "正好等于裸 ESC",与 isInterruptInput 同样的判定。
|
|
827
947
|
function isPendingClearingInput(data) {
|
|
828
948
|
if (typeof data !== 'string' || !data) return false
|
|
829
|
-
|
|
949
|
+
if (/[\r\n\x03\x04]/.test(data)) return true
|
|
950
|
+
if (data === '\x1b' || data === '\x1b\x1b') return true
|
|
951
|
+
return false
|
|
830
952
|
}
|
|
831
953
|
|
|
832
954
|
// 用户希望"打断当前轮"的按键:Ctrl+C(\x03)或 Esc(裸 \x1b / \x1b\x1b)。
|
|
@@ -993,6 +1115,11 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
993
1115
|
const session = sessions.get(sessionId)
|
|
994
1116
|
if (!session) return
|
|
995
1117
|
if (!isValidResizeSize(cols, rows)) return
|
|
1118
|
+
// role 字段可选:addBrowser 时已经按 WS URL ?role= 设过 ws.__quadtodoRole;
|
|
1119
|
+
// 这里只在 init 显式带 role 时才覆盖(保留首次"primary"声明不被无 role 的 init 静默打回)
|
|
1120
|
+
if (ws && (msg.role === 'primary' || msg.role === 'secondary')) {
|
|
1121
|
+
ws.__quadtodoRole = msg.role
|
|
1122
|
+
}
|
|
996
1123
|
if (!session.spawned) {
|
|
997
1124
|
if (session.spawnFallbackTimer) {
|
|
998
1125
|
clearTimeout(session.spawnFallbackTimer)
|
|
@@ -1023,6 +1150,11 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1023
1150
|
const rows = Number(msg.rows)
|
|
1024
1151
|
const session = sessions.get(sessionId)
|
|
1025
1152
|
if (!canResizeSession(session)) return
|
|
1153
|
+
// role 字段可选:只在显式传入时更新,避免普通 resize 把已声明的 primary
|
|
1154
|
+
// 静默打回 secondary
|
|
1155
|
+
if (ws && (msg.role === 'primary' || msg.role === 'secondary')) {
|
|
1156
|
+
ws.__quadtodoRole = msg.role
|
|
1157
|
+
}
|
|
1026
1158
|
if (ws && session.browsers.has(ws)) {
|
|
1027
1159
|
if (!isValidResizeSize(cols, rows)) {
|
|
1028
1160
|
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 默认有写权限,慎重
|