Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
586 changes: 547 additions & 39 deletions Sources/CodeIsland/AppState.swift

Large diffs are not rendered by default.

49 changes: 39 additions & 10 deletions Sources/CodeIsland/HookServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ class HookServer {
private static let pluginMarkerBytes = Data("_via_plugin".utf8)
private static let sourceMarkerBytes = Data(#""_source""#.utf8)
private static let codexMarkerBytes = Data("codex".utf8)
private static let cursorTranscriptMarkerBytes = Data("agent-transcripts".utf8)
private static let cursorSourceMarkerBytes = Data("cursor".utf8)

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

private func routeSubsessionPayloadIfNeeded(data: Data) -> (processedData: Data, responseData: Data?) {
let mayNeedRouting = data.range(of: Self.pluginMarkerBytes) != nil
let mayNeedPluginOrCodex = data.range(of: Self.pluginMarkerBytes) != nil
|| (data.range(of: Self.sourceMarkerBytes) != nil && data.range(of: Self.codexMarkerBytes) != nil)
guard mayNeedRouting,
let mayNeedCursor = data.range(of: Self.cursorTranscriptMarkerBytes) != nil
&& data.range(of: Self.cursorSourceMarkerBytes) != nil

let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode)
?? SettingsDefaults.pluginSessionMode

// Cursor Task routing is a no-op in separate mode; skip JSON parse when
// the payload is Cursor-only (Codex/plugin may still need it below).
if mayNeedCursor && !mayNeedPluginOrCodex && mode != "hide" && mode != "merge" {
return (data, nil)
}

guard mayNeedPluginOrCodex || mayNeedCursor,
let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (data, nil)
}

let mode = UserDefaults.standard.string(forKey: SettingsKey.pluginSessionMode)
?? SettingsDefaults.pluginSessionMode
// Cursor Task/subagent: apply Agent Sub-Sessions (separate / merge / hide).
switch CursorSubsessionRouter.decide(raw: raw, mode: mode) {
case .leave:
break
case .hide:
return (data, Self.hiddenPluginResponse(for: raw))
case .merge(let parentSessionId, let childSessionId):
var rewritten = raw
CursorSubsessionRouter.applyMerge(
to: &rewritten,
parentSessionId: parentSessionId,
childSessionId: childSessionId
)
if let newData = try? JSONSerialization.data(withJSONObject: rewritten) {
return (newData, nil)
}
return (data, nil)
}

guard mode == "hide" || mode == "merge" else {
return (data, nil)
}
Expand Down Expand Up @@ -414,13 +445,11 @@ class HookServer {
}

private func processRequest(data: Data, connection: NWConnection) {
// Sub-session mode pre-filter (#123, #151): events that arrived through a
// plugin proxy (`_via_plugin`) or from a Codex native subagent can be
// merged into the matching main session, hidden, or kept separate per
// the user's setting. "separate" preserves prior behavior.
// Sub-session pre-filter (#123, #151): plugin, Codex, or Cursor Task hooks
// (Cursor: transcript parent ≠ session_id) follow Agent Sub-Sessions —
// merge into the parent, hide, or keep separate.
//
// Cheap byte probes first — JSONSerialization on every PostToolUse on
// the main thread is not free.
// Cheap byte probes first; avoid JSONSerialization on every PostToolUse.
let routed = routeSubsessionPayloadIfNeeded(data: data)
if let responseData = routed.responseData {
sendResponse(connection: connection, data: responseData)
Expand Down
14 changes: 7 additions & 7 deletions Sources/CodeIsland/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "Tool History Limit",
"tool_history_limit_desc": "Max number of recent tool calls shown per session",
"plugin_session_mode": "Agent Sub-Sessions",
"plugin_session_mode_desc": "How to show child sessions spawned by agents, including Codex subagents and plugin hook events (e.g. omo in OpenCode)",
"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)",
"plugin_session_mode_separate": "Show separately",
"plugin_session_mode_merge": "Merge into main",
"plugin_session_mode_hide": "Hide",
Expand Down Expand Up @@ -466,7 +466,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "Tool-Verlaufslimit",
"tool_history_limit_desc": "Maximale Anzahl der zuletzt angezeigten Tool-Aufrufe pro Sitzung",
"plugin_session_mode": "Agent-Sub-Sitzungen",
"plugin_session_mode_desc": "Darstellung von durch Agenten gestarteten Kind-Sitzungen, einschließlich Codex-Subagenten und Plugin-Hook-Ereignissen (z. B. omo in OpenCode)",
"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)",
"plugin_session_mode_separate": "Separat anzeigen",
"plugin_session_mode_merge": "In Hauptsitzung zusammenführen",
"plugin_session_mode_hide": "Ausblenden",
Expand Down Expand Up @@ -806,7 +806,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "工具历史上限",
"tool_history_limit_desc": "每个会话显示的最近工具调用数量上限",
"plugin_session_mode": "Agent 子会话",
"plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent 和插件 hook 事件(例如 OpenCode 中的 omo)",
"plugin_session_mode_desc": "处理由 agent 派生的子会话,包括 Codex subagent、Cursor IDE Task/subagent 和插件 hook 事件(例如 OpenCode 中的 omo)",
"plugin_session_mode_separate": "独立显示",
"plugin_session_mode_merge": "合并到主会话",
"plugin_session_mode_hide": "隐藏",
Expand Down Expand Up @@ -1146,7 +1146,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "工具歷史上限",
"tool_history_limit_desc": "每個會話顯示的最近工具呼叫數量上限",
"plugin_session_mode": "Agent 子會話",
"plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)",
"plugin_session_mode_desc": "處理由 agent 衍生的子會話,包括 Codex subagent、Cursor IDE Task/subagent 和外掛 hook 事件(例如 OpenCode 中的 omo)",
"plugin_session_mode_separate": "獨立顯示",
"plugin_session_mode_merge": "合併到主會話",
"plugin_session_mode_hide": "隱藏",
Expand Down Expand Up @@ -1486,7 +1486,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "ツール履歴の上限",
"tool_history_limit_desc": "セッションごとに表示する最近のツール呼び出し数の上限です",
"plugin_session_mode": "エージェントのサブセッション",
"plugin_session_mode_desc": "Codex subagent やプラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)",
"plugin_session_mode_desc": "Codex subagent、Cursor IDE Task/subagent、プラグイン hook イベントなど、エージェントから派生した子セッションの表示方法を選択します(例: OpenCode の omo)",
"plugin_session_mode_separate": "別々に表示",
"plugin_session_mode_merge": "メインセッションに統合",
"plugin_session_mode_hide": "非表示",
Expand Down Expand Up @@ -1826,7 +1826,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "도구 기록 제한",
"tool_history_limit_desc": "세션별로 표시할 최근 도구 호출의 최대 개수입니다",
"plugin_session_mode": "에이전트 하위 세션",
"plugin_session_mode_desc": "Codex subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)",
"plugin_session_mode_desc": "Codex subagent, Cursor IDE Task/subagent 및 플러그인 hook 이벤트 등 에이전트가 만든 하위 세션을 표시하는 방식 (예: OpenCode의 omo)",
"plugin_session_mode_separate": "별도로 표시",
"plugin_session_mode_merge": "메인 세션으로 병합",
"plugin_session_mode_hide": "숨김",
Expand Down Expand Up @@ -2166,7 +2166,7 @@ final class L10n: ObservableObject {
"tool_history_limit": "Araç Geçmişi Limiti",
"tool_history_limit_desc": "Oturum başına gösterilen son araç çağrı sayısı",
"plugin_session_mode": "Ajan Alt Oturumları",
"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)",
"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)",
"plugin_session_mode_separate": "Ayrı göster",
"plugin_session_mode_merge": "Ana oturuma birleştir",
"plugin_session_mode_hide": "Gizle",
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodeIsland/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ struct PersistedSession: Codable {
let cliStartTime: Date?
let startTime: Date
let lastActivity: Date
/// Absolute JSONL path for session fold and transcript tailing.
let transcriptPath: String?
/// Closed subagent ids; kept across relaunch so merge cannot revive them.
let closedSubagentIds: [String]?
}

enum SessionPersistence {
Expand Down Expand Up @@ -66,7 +70,9 @@ enum SessionPersistence {
cliPid: s.cliPid,
cliStartTime: s.cliStartTime,
startTime: s.startTime,
lastActivity: s.lastActivity
lastActivity: s.lastActivity,
transcriptPath: s.transcriptPath,
closedSubagentIds: s.closedSubagentIds.isEmpty ? nil : Array(s.closedSubagentIds).sorted()
)
}
do {
Expand Down
114 changes: 114 additions & 0 deletions Sources/CodeIslandCore/CursorSessionFolding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

/// Maps Cursor Task/subagent hooks onto their parent chat card.
///
/// Child hooks use a different `session_id`, but `transcript_path` still points at
/// `…/agent-transcripts/<parentId>/…`. Without rewriting, each Task appears as a
/// duplicate Cursor card for the same IDE conversation.
public enum CursorSessionFolding {
/// UUID segment under `agent-transcripts/`.
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}$"#

/// Parent conversation id from a Cursor `transcript_path`, when present.
///
/// Recognized layouts:
/// - `…/agent-transcripts/<parentId>/<parentId>.jsonl`
/// - `…/agent-transcripts/<parentId>/subagents/<childId>.jsonl`
public static func parentConversationId(fromTranscriptPath path: String) -> String? {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let parts = (trimmed as NSString).pathComponents
guard let transcriptsIdx = parts.lastIndex(of: "agent-transcripts"),
transcriptsIdx + 1 < parts.count else {
return nil
}

let parentCandidate = parts[transcriptsIdx + 1]
guard parentCandidate.range(of: uuidDirPattern, options: .regularExpression) != nil else {
return nil
}
return parentCandidate
}

/// Parent id when `childSessionId` is a Task of that conversation
/// (`transcript_path` parent ≠ `childSessionId`).
public static func foldTarget(childSessionId: String, transcriptPath: String?) -> String? {
let child = childSessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !child.isEmpty,
let path = transcriptPath,
let parentId = parentConversationId(fromTranscriptPath: path),
parentId != child else {
return nil
}
return parentId
}
}

/// How to handle a Cursor Task/subagent hook under Agent Sub-Sessions.
public enum CursorSubsessionRouteDecision: Equatable {
case leave
case hide
case merge(parentSessionId: String, childSessionId: String)
}

public enum CursorSubsessionRouter {
/// Apply Agent Sub-Sessions (`separate` / `merge` / `hide`) to Cursor hooks.
/// Same three modes as Codex and plugin children.
public static func decide(raw: [String: Any], mode: String) -> CursorSubsessionRouteDecision {
let normalizedSource = SessionSnapshot.normalizedSupportedSource(
(raw["_source"] as? String) ?? (raw["source"] as? String)
)
guard normalizedSource == "cursor" || normalizedSource == "cursor-cli" else {
return .leave
}

let childSessionId = nonEmptyString(raw["session_id"]) ?? nonEmptyString(raw["sessionId"])
guard let childSessionId else { return .leave }

let transcriptPath = nonEmptyString(raw["transcript_path"]) ?? nonEmptyString(raw["transcriptPath"])
guard let parentId = CursorSessionFolding.foldTarget(
childSessionId: childSessionId,
transcriptPath: transcriptPath
) else {
return .leave
}

switch mode {
case "hide":
return .hide
case "merge":
return .merge(parentSessionId: parentId, childSessionId: childSessionId)
default:
// separate (or unknown): leave as its own session card
return .leave
}
}

/// Rewrite the hook onto the parent session and attach `agent_id` = child.
public static func applyMerge(
to raw: inout [String: Any],
parentSessionId: String,
childSessionId: String
) {
raw["session_id"] = parentSessionId
raw["agent_id"] = nonEmptyString(raw["agent_id"]) ?? childSessionId
if nonEmptyString(raw["agent_type"]) == nil {
raw["agent_type"] = "cursor-subagent"
}
raw["_cursor_subagent"] = true
raw["_cursor_subagent_session_id"] = childSessionId
if let eventName = nonEmptyString(raw["hook_event_name"])
?? nonEmptyString(raw["hookEventName"])
?? nonEmptyString(raw["event_name"])
?? nonEmptyString(raw["eventName"]) {
raw["_cursor_subagent_event"] = EventNormalizer.normalize(eventName)
}
}

private static func nonEmptyString(_ value: Any?) -> String? {
guard let string = value as? String else { return nil }
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Loading