Skip to content

Commit d041a14

Browse files
authored
fix(sessions): fold Cursor Tasks under Agent Sub-Sessions like Codex (#262)
* fix(sessions): fold Cursor Tasks under Agent Sub-Sessions like Codex Cursor IDE Tasks use a child session_id while transcript_path still points at the parent chat, which created duplicate cards. Honor separate/merge/hide, keep closed subagent ids across Stop (and idle-orphan merge drops), deny late Permission/AskUserQuestion/Question for closed agent ids, drop idle orphans without resuscitating via a shared IDE pid, fill missing parent identity from merge-first Task hooks, reopen Cursor Tasks via beforeSubmitPrompt after Stop, keep the parent active while folded Tasks run, and avoid dual-tailing the parent transcript when splitting to separate cards. * fix(sessions): harden Cursor Task fold permission and Stop edge cases Keep folded deny/answer from idling the parent, drop idle orphans without overwriting live Tasks, and only self-tombstone foldable separate Task cards. * fix(appstate): allow AppState deinit off the main actor Async XCTest teardown released AppState off MainActor and trapped in assumeIsolated; use weak-boxed FSEvents teardown so discovery stays safe.
1 parent 48f2cdc commit d041a14

10 files changed

Lines changed: 2486 additions & 73 deletions

Sources/CodeIsland/AppState.swift

Lines changed: 547 additions & 39 deletions
Large diffs are not rendered by default.

Sources/CodeIsland/HookServer.swift

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ class HookServer {
312312
private static let pluginMarkerBytes = Data("_via_plugin".utf8)
313313
private static let sourceMarkerBytes = Data(#""_source""#.utf8)
314314
private static let codexMarkerBytes = Data("codex".utf8)
315+
private static let cursorTranscriptMarkerBytes = Data("agent-transcripts".utf8)
316+
private static let cursorSourceMarkerBytes = Data("cursor".utf8)
315317

316318
private static func codexSubagentMetadata(from raw: [String: Any]) -> CodexSubagentMetadata? {
317319
guard let path = nonEmptyString(raw["transcript_path"]) else { return nil }
@@ -346,15 +348,44 @@ class HookServer {
346348
}
347349

348350
private func routeSubsessionPayloadIfNeeded(data: Data) -> (processedData: Data, responseData: Data?) {
349-
let mayNeedRouting = data.range(of: Self.pluginMarkerBytes) != nil
351+
let mayNeedPluginOrCodex = data.range(of: Self.pluginMarkerBytes) != nil
350352
|| (data.range(of: Self.sourceMarkerBytes) != nil && data.range(of: Self.codexMarkerBytes) != nil)
351-
guard mayNeedRouting,
353+
let mayNeedCursor = data.range(of: Self.cursorTranscriptMarkerBytes) != nil
354+
&& data.range(of: Self.cursorSourceMarkerBytes) != nil
355+
356+
let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode)
357+
?? SettingsDefaults.pluginSessionMode
358+
359+
// Cursor Task routing is a no-op in separate mode; skip JSON parse when
360+
// the payload is Cursor-only (Codex/plugin may still need it below).
361+
if mayNeedCursor && !mayNeedPluginOrCodex && mode != "hide" && mode != "merge" {
362+
return (data, nil)
363+
}
364+
365+
guard mayNeedPluginOrCodex || mayNeedCursor,
352366
let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
353367
return (data, nil)
354368
}
355369

356-
let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode)
357-
?? SettingsDefaults.pluginSessionMode
370+
// Cursor Task/subagent: apply Agent Sub-Sessions (separate / merge / hide).
371+
switch CursorSubsessionRouter.decide(raw: raw, mode: mode) {
372+
case .leave:
373+
break
374+
case .hide:
375+
return (data, Self.hiddenPluginResponse(for: raw))
376+
case .merge(let parentSessionId, let childSessionId):
377+
var rewritten = raw
378+
CursorSubsessionRouter.applyMerge(
379+
to: &rewritten,
380+
parentSessionId: parentSessionId,
381+
childSessionId: childSessionId
382+
)
383+
if let newData = try? JSONSerialization.data(withJSONObject: rewritten) {
384+
return (newData, nil)
385+
}
386+
return (data, nil)
387+
}
388+
358389
guard mode == "hide" || mode == "merge" else {
359390
return (data, nil)
360391
}
@@ -414,13 +445,11 @@ class HookServer {
414445
}
415446

416447
private func processRequest(data: Data, connection: NWConnection) {
417-
// Sub-session mode pre-filter (#123, #151): events that arrived through a
418-
// plugin proxy (`_via_plugin`) or from a Codex native subagent can be
419-
// merged into the matching main session, hidden, or kept separate per
420-
// the user's setting. "separate" preserves prior behavior.
448+
// Sub-session pre-filter (#123, #151): plugin, Codex, or Cursor Task hooks
449+
// (Cursor: transcript parent ≠ session_id) follow Agent Sub-Sessions —
450+
// merge into the parent, hide, or keep separate.
421451
//
422-
// Cheap byte probes first — JSONSerialization on every PostToolUse on
423-
// the main thread is not free.
452+
// Cheap byte probes first; avoid JSONSerialization on every PostToolUse.
424453
let routed = routeSubsessionPayloadIfNeeded(data: data)
425454
if let responseData = routed.responseData {
426455
sendResponse(connection: connection, data: responseData)

Sources/CodeIsland/L10n.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ final class L10n: ObservableObject {
126126
"tool_history_limit": "Tool History Limit",
127127
"tool_history_limit_desc": "Max number of recent tool calls shown per session",
128128
"plugin_session_mode": "Agent Sub-Sessions",
129-
"plugin_session_mode_desc": "How to show child sessions spawned by agents, including Codex subagents and plugin hook events (e.g. omo in OpenCode)",
129+
"plugin_session_mode_desc": "How to show child sessions spawned by agents, including Codex subagents, Cursor IDE Task/subagents, and plugin hook events (e.g. omo in OpenCode)",
130130
"plugin_session_mode_separate": "Show separately",
131131
"plugin_session_mode_merge": "Merge into main",
132132
"plugin_session_mode_hide": "Hide",
@@ -470,7 +470,7 @@ final class L10n: ObservableObject {
470470
"tool_history_limit": "Tool-Verlaufslimit",
471471
"tool_history_limit_desc": "Maximale Anzahl der zuletzt angezeigten Tool-Aufrufe pro Sitzung",
472472
"plugin_session_mode": "Agent-Sub-Sitzungen",
473-
"plugin_session_mode_desc": "Darstellung von durch Agenten gestarteten Kind-Sitzungen, einschließlich Codex-Subagenten und Plugin-Hook-Ereignissen (z. B. omo in OpenCode)",
473+
"plugin_session_mode_desc": "Darstellung von durch Agenten gestarteten Kind-Sitzungen, einschließlich Codex-Subagenten, Cursor-IDE-Task/Subagenten und Plugin-Hook-Ereignissen (z. B. omo in OpenCode)",
474474
"plugin_session_mode_separate": "Separat anzeigen",
475475
"plugin_session_mode_merge": "In Hauptsitzung zusammenführen",
476476
"plugin_session_mode_hide": "Ausblenden",
@@ -810,7 +810,7 @@ final class L10n: ObservableObject {
810810
"tool_history_limit": "工具历史上限",
811811
"tool_history_limit_desc": "每个会话显示的最近工具调用数量上限",
812812
"plugin_session_mode": "Agent 子会话",
813-
"plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent 和插件 hook 事件(例如 OpenCode 中的 omo)",
813+
"plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent、Cursor IDE Task/subagent 和插件 hook 事件(例如 OpenCode 中的 omo)",
814814
"plugin_session_mode_separate": "独立显示",
815815
"plugin_session_mode_merge": "合并到主会话",
816816
"plugin_session_mode_hide": "隐藏",
@@ -1154,7 +1154,7 @@ final class L10n: ObservableObject {
11541154
"tool_history_limit": "工具歷史上限",
11551155
"tool_history_limit_desc": "每個會話顯示的最近工具呼叫數量上限",
11561156
"plugin_session_mode": "Agent 子會話",
1157-
"plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)",
1157+
"plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent、Cursor IDE Task/subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)",
11581158
"plugin_session_mode_separate": "獨立顯示",
11591159
"plugin_session_mode_merge": "合併到主會話",
11601160
"plugin_session_mode_hide": "隱藏",
@@ -1498,7 +1498,7 @@ final class L10n: ObservableObject {
14981498
"tool_history_limit": "ツール履歴の上限",
14991499
"tool_history_limit_desc": "セッションごとに表示する最近のツール呼び出し数の上限です",
15001500
"plugin_session_mode": "エージェントのサブセッション",
1501-
"plugin_session_mode_desc": "Codex subagent やプラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)",
1501+
"plugin_session_mode_desc": "Codex subagent、Cursor IDE Task/subagent、プラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)",
15021502
"plugin_session_mode_separate": "別々に表示",
15031503
"plugin_session_mode_merge": "メインセッションに統合",
15041504
"plugin_session_mode_hide": "非表示",
@@ -1842,7 +1842,7 @@ final class L10n: ObservableObject {
18421842
"tool_history_limit": "도구 기록 제한",
18431843
"tool_history_limit_desc": "세션별로 표시할 최근 도구 호출의 최대 개수입니다",
18441844
"plugin_session_mode": "에이전트 하위 세션",
1845-
"plugin_session_mode_desc": "Codex subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)",
1845+
"plugin_session_mode_desc": "Codex subagent, Cursor IDE Task/subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)",
18461846
"plugin_session_mode_separate": "별도로 표시",
18471847
"plugin_session_mode_merge": "메인 세션으로 병합",
18481848
"plugin_session_mode_hide": "숨김",
@@ -2186,7 +2186,7 @@ final class L10n: ObservableObject {
21862186
"tool_history_limit": "Araç Geçmişi Limiti",
21872187
"tool_history_limit_desc": "Oturum başına gösterilen son araç çağrı sayısı",
21882188
"plugin_session_mode": "Ajan Alt Oturumları",
2189-
"plugin_session_mode_desc": "Codex subagent'ları ve eklenti hook olayları dahil, ajanların başlattığı alt oturumların nasıl gösterileceği (örn: OpenCode içinde omo)",
2189+
"plugin_session_mode_desc": "Codex subagent'ları, Cursor IDE Task/subagent'ları ve eklenti hook olayları dahil, ajanların başlattığı alt oturumların nasıl gösterileceği (örn: OpenCode içinde omo)",
21902190
"plugin_session_mode_separate": "Ayrı göster",
21912191
"plugin_session_mode_merge": "Ana oturuma birleştir",
21922192
"plugin_session_mode_hide": "Gizle",

Sources/CodeIsland/SessionPersistence.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ struct PersistedSession: Codable {
3131
let cliStartTime: Date?
3232
let startTime: Date
3333
let lastActivity: Date
34+
/// Absolute JSONL path for session fold and transcript tailing.
35+
let transcriptPath: String?
36+
/// Closed subagent ids; kept across relaunch so merge cannot revive them.
37+
let closedSubagentIds: [String]?
3438
}
3539

3640
enum SessionPersistence {
@@ -66,7 +70,9 @@ enum SessionPersistence {
6670
cliPid: s.cliPid,
6771
cliStartTime: s.cliStartTime,
6872
startTime: s.startTime,
69-
lastActivity: s.lastActivity
73+
lastActivity: s.lastActivity,
74+
transcriptPath: s.transcriptPath,
75+
closedSubagentIds: s.closedSubagentIds.isEmpty ? nil : Array(s.closedSubagentIds).sorted()
7076
)
7177
}
7278
do {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import Foundation
2+
3+
/// Maps Cursor Task/subagent hooks onto their parent chat card.
4+
///
5+
/// Child hooks use a different `session_id`, but `transcript_path` still points at
6+
/// `…/agent-transcripts/<parentId>/…`. Without rewriting, each Task appears as a
7+
/// duplicate Cursor card for the same IDE conversation.
8+
public enum CursorSessionFolding {
9+
/// UUID segment under `agent-transcripts/`.
10+
private static let uuidDirPattern = #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
11+
12+
/// Parent conversation id from a Cursor `transcript_path`, when present.
13+
///
14+
/// Recognized layouts:
15+
/// - `…/agent-transcripts/<parentId>/<parentId>.jsonl`
16+
/// - `…/agent-transcripts/<parentId>/subagents/<childId>.jsonl`
17+
public static func parentConversationId(fromTranscriptPath path: String) -> String? {
18+
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
19+
guard !trimmed.isEmpty else { return nil }
20+
21+
let parts = (trimmed as NSString).pathComponents
22+
guard let transcriptsIdx = parts.lastIndex(of: "agent-transcripts"),
23+
transcriptsIdx + 1 < parts.count else {
24+
return nil
25+
}
26+
27+
let parentCandidate = parts[transcriptsIdx + 1]
28+
guard parentCandidate.range(of: uuidDirPattern, options: .regularExpression) != nil else {
29+
return nil
30+
}
31+
return parentCandidate
32+
}
33+
34+
/// Parent id when `childSessionId` is a Task of that conversation
35+
/// (`transcript_path` parent ≠ `childSessionId`).
36+
public static func foldTarget(childSessionId: String, transcriptPath: String?) -> String? {
37+
let child = childSessionId.trimmingCharacters(in: .whitespacesAndNewlines)
38+
guard !child.isEmpty,
39+
let path = transcriptPath,
40+
let parentId = parentConversationId(fromTranscriptPath: path),
41+
parentId != child else {
42+
return nil
43+
}
44+
return parentId
45+
}
46+
}
47+
48+
/// How to handle a Cursor Task/subagent hook under Agent Sub-Sessions.
49+
public enum CursorSubsessionRouteDecision: Equatable {
50+
case leave
51+
case hide
52+
case merge(parentSessionId: String, childSessionId: String)
53+
}
54+
55+
public enum CursorSubsessionRouter {
56+
/// Apply Agent Sub-Sessions (`separate` / `merge` / `hide`) to Cursor hooks.
57+
/// Same three modes as Codex and plugin children.
58+
public static func decide(raw: [String: Any], mode: String) -> CursorSubsessionRouteDecision {
59+
let normalizedSource = SessionSnapshot.normalizedSupportedSource(
60+
(raw["_source"] as? String) ?? (raw["source"] as? String)
61+
)
62+
guard normalizedSource == "cursor" || normalizedSource == "cursor-cli" else {
63+
return .leave
64+
}
65+
66+
let childSessionId = nonEmptyString(raw["session_id"]) ?? nonEmptyString(raw["sessionId"])
67+
guard let childSessionId else { return .leave }
68+
69+
let transcriptPath = nonEmptyString(raw["transcript_path"]) ?? nonEmptyString(raw["transcriptPath"])
70+
guard let parentId = CursorSessionFolding.foldTarget(
71+
childSessionId: childSessionId,
72+
transcriptPath: transcriptPath
73+
) else {
74+
return .leave
75+
}
76+
77+
switch mode {
78+
case "hide":
79+
return .hide
80+
case "merge":
81+
return .merge(parentSessionId: parentId, childSessionId: childSessionId)
82+
default:
83+
// separate (or unknown): leave as its own session card
84+
return .leave
85+
}
86+
}
87+
88+
/// Rewrite the hook onto the parent session and attach `agent_id` = child.
89+
public static func applyMerge(
90+
to raw: inout [String: Any],
91+
parentSessionId: String,
92+
childSessionId: String
93+
) {
94+
raw["session_id"] = parentSessionId
95+
raw["agent_id"] = nonEmptyString(raw["agent_id"]) ?? childSessionId
96+
if nonEmptyString(raw["agent_type"]) == nil {
97+
raw["agent_type"] = "cursor-subagent"
98+
}
99+
raw["_cursor_subagent"] = true
100+
raw["_cursor_subagent_session_id"] = childSessionId
101+
if let eventName = nonEmptyString(raw["hook_event_name"])
102+
?? nonEmptyString(raw["hookEventName"])
103+
?? nonEmptyString(raw["event_name"])
104+
?? nonEmptyString(raw["eventName"]) {
105+
raw["_cursor_subagent_event"] = EventNormalizer.normalize(eventName)
106+
}
107+
}
108+
109+
private static func nonEmptyString(_ value: Any?) -> String? {
110+
guard let string = value as? String else { return nil }
111+
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
112+
return trimmed.isEmpty ? nil : trimmed
113+
}
114+
}

0 commit comments

Comments
 (0)