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