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/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) session.recentOutput = ''
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
- function markPendingConfirm(sessionId, { source = null } = {}) {
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
- if (session.status === 'pending_confirm') return true
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
- // 取所有在线浏览器上报尺寸的 **最小值** 发给 PTY:最窄的窗口看得下,更宽的
806
- // tab 只是右边留空白,整体输出保持稳定。
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 cols = Infinity
811
- let rows = Infinity
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 (sz.cols < cols) cols = sz.cols
816
- if (sz.rows < rows) rows = sz.rows
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 —— 这些才会真正让 Claude/Codex 的 confirm 提示推进
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
- return /[\r\n\x03\x04]/.test(data)
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 默认有写权限,慎重