@@ -1074,3 +1074,268 @@ def get_session_messages(
10741074 if offset > 0 :
10751075 return messages [offset :]
10761076 return messages
1077+
1078+
1079+ # ---------------------------------------------------------------------------
1080+ # list_subagents / get_subagent_messages — subagent transcript reading
1081+ # ---------------------------------------------------------------------------
1082+
1083+
1084+ def _resolve_session_file_path (session_id : str , directory : str | None ) -> Path | None :
1085+ """Resolves the on-disk path of a session JSONL file.
1086+
1087+ Directory resolution mirrors ``_read_session_file``: when ``directory``
1088+ is provided, looks in that project directory and its git worktrees;
1089+ otherwise searches all project directories. Returns the path of the
1090+ first non-empty match, or ``None`` if not found.
1091+ """
1092+ file_name = f"{ session_id } .jsonl"
1093+
1094+ def _stat_candidate (project_dir : Path ) -> Path | None :
1095+ candidate = project_dir / file_name
1096+ try :
1097+ if candidate .stat ().st_size > 0 :
1098+ return candidate
1099+ except OSError :
1100+ pass
1101+ return None
1102+
1103+ if directory :
1104+ canonical_dir = _canonicalize_path (directory )
1105+
1106+ project_dir = _find_project_dir (canonical_dir )
1107+ if project_dir is not None :
1108+ found = _stat_candidate (project_dir )
1109+ if found is not None :
1110+ return found
1111+
1112+ try :
1113+ worktree_paths = _get_worktree_paths (canonical_dir )
1114+ except Exception :
1115+ worktree_paths = []
1116+
1117+ for wt in worktree_paths :
1118+ if wt == canonical_dir :
1119+ continue
1120+ wt_project_dir = _find_project_dir (wt )
1121+ if wt_project_dir is not None :
1122+ found = _stat_candidate (wt_project_dir )
1123+ if found is not None :
1124+ return found
1125+
1126+ return None
1127+
1128+ projects_dir = _get_projects_dir ()
1129+ try :
1130+ dirents = list (projects_dir .iterdir ())
1131+ except OSError :
1132+ return None
1133+
1134+ for entry in dirents :
1135+ if not entry .is_dir ():
1136+ continue
1137+ found = _stat_candidate (entry )
1138+ if found is not None :
1139+ return found
1140+
1141+ return None
1142+
1143+
1144+ def _resolve_subagents_dir (session_id : str , directory : str | None ) -> Path | None :
1145+ """Resolves the subagents directory for a given session.
1146+
1147+ The session file lives at ``<projectDir>/<sessionId>.jsonl`` and the
1148+ subagents directory at ``<projectDir>/<sessionId>/subagents/``.
1149+
1150+ Returns ``None`` if the session cannot be found.
1151+ """
1152+ resolved = _resolve_session_file_path (session_id , directory )
1153+ if resolved is None :
1154+ return None
1155+ # Strip the .jsonl suffix to derive the session directory.
1156+ session_dir = resolved .with_suffix ("" )
1157+ return session_dir / "subagents"
1158+
1159+
1160+ def _collect_agent_files (base_dir : Path ) -> list [tuple [str , Path ]]:
1161+ """Recursively collects ``agent-*.jsonl`` files from a directory tree.
1162+
1163+ Subagent transcripts may live directly in ``subagents/`` or in nested
1164+ subdirectories such as ``subagents/workflows/<runId>/``.
1165+
1166+ Returns a list of ``(agent_id, file_path)`` tuples.
1167+ """
1168+ results : list [tuple [str , Path ]] = []
1169+
1170+ def _walk (current_dir : Path ) -> None :
1171+ try :
1172+ dirents = sorted (current_dir .iterdir (), key = lambda p : p .name )
1173+ except OSError :
1174+ return
1175+ for entry in dirents :
1176+ name = entry .name
1177+ if (
1178+ entry .is_file ()
1179+ and name .startswith ("agent-" )
1180+ and name .endswith (".jsonl" )
1181+ ):
1182+ agent_id = name [len ("agent-" ) : - len (".jsonl" )]
1183+ results .append ((agent_id , entry ))
1184+ elif entry .is_dir ():
1185+ _walk (entry )
1186+
1187+ _walk (base_dir )
1188+ return results
1189+
1190+
1191+ def _build_subagent_chain (entries : list [_TranscriptEntry ]) -> list [_TranscriptEntry ]:
1192+ """Builds the conversation chain for a subagent transcript.
1193+
1194+ Subagent transcripts are simpler than main sessions — no compaction,
1195+ no sidechains, no preserved segments. Find the last user/assistant
1196+ entry and walk ``parentUuid`` links back to the root.
1197+ """
1198+ if not entries :
1199+ return []
1200+
1201+ by_uuid : dict [str , _TranscriptEntry ] = {}
1202+ for entry in entries :
1203+ by_uuid [entry ["uuid" ]] = entry
1204+
1205+ # Subagent transcripts are linear — the last user/assistant entry is
1206+ # the leaf.
1207+ leaf : _TranscriptEntry | None = None
1208+ for entry in reversed (entries ):
1209+ if entry .get ("type" ) in ("user" , "assistant" ):
1210+ leaf = entry
1211+ break
1212+ if leaf is None :
1213+ return []
1214+
1215+ chain : list [_TranscriptEntry ] = []
1216+ seen : set [str ] = set ()
1217+ current : _TranscriptEntry | None = leaf
1218+ while current is not None :
1219+ uid = current ["uuid" ]
1220+ if uid in seen :
1221+ break
1222+ seen .add (uid )
1223+ chain .append (current )
1224+ parent = current .get ("parentUuid" )
1225+ current = by_uuid .get (parent ) if parent else None
1226+
1227+ chain .reverse ()
1228+ return chain
1229+
1230+
1231+ def list_subagents (
1232+ session_id : str ,
1233+ directory : str | None = None ,
1234+ ) -> list [str ]:
1235+ """Lists subagent IDs for a given session by scanning the subagents directory.
1236+
1237+ Subagent transcripts are stored at
1238+ ``~/.claude/projects/<project>/<sessionId>/subagents/agent-<agentId>.jsonl``
1239+ (and may be nested in subdirectories such as ``workflows/<runId>/``).
1240+
1241+ Args:
1242+ session_id: UUID of the parent session.
1243+ directory: Project directory to find the session in. If omitted,
1244+ searches all project directories under ``~/.claude/projects/``.
1245+
1246+ Returns:
1247+ List of subagent ID strings. Returns an empty list if the session
1248+ is not found, the session_id is not a valid UUID, or the session
1249+ has no subagents.
1250+
1251+ Example:
1252+ List subagent IDs for a session::
1253+
1254+ agent_ids = list_subagents(
1255+ "550e8400-e29b-41d4-a716-446655440000",
1256+ directory="/path/to/project",
1257+ )
1258+ """
1259+ if not _validate_uuid (session_id ):
1260+ return []
1261+
1262+ subagents_dir = _resolve_subagents_dir (session_id , directory )
1263+ if subagents_dir is None :
1264+ return []
1265+
1266+ return [agent_id for agent_id , _ in _collect_agent_files (subagents_dir )]
1267+
1268+
1269+ def get_subagent_messages (
1270+ session_id : str ,
1271+ agent_id : str ,
1272+ directory : str | None = None ,
1273+ limit : int | None = None ,
1274+ offset : int = 0 ,
1275+ ) -> list [SessionMessage ]:
1276+ """Reads a subagent's conversation messages from its JSONL transcript file.
1277+
1278+ Parses the subagent transcript, builds the conversation chain via
1279+ ``parentUuid`` links, and returns user/assistant messages in
1280+ chronological order.
1281+
1282+ Args:
1283+ session_id: UUID of the parent session.
1284+ agent_id: ID of the subagent (as returned by ``list_subagents``).
1285+ directory: Project directory to find the session in. If omitted,
1286+ searches all project directories under ``~/.claude/projects/``.
1287+ limit: Maximum number of messages to return.
1288+ offset: Number of messages to skip from the start.
1289+
1290+ Returns:
1291+ List of ``SessionMessage`` objects in chronological order. Returns
1292+ an empty list if the session or subagent is not found, the
1293+ session_id is not a valid UUID, or the transcript contains no
1294+ user/assistant messages.
1295+
1296+ Example:
1297+ Read all messages from a subagent::
1298+
1299+ messages = get_subagent_messages(
1300+ "550e8400-e29b-41d4-a716-446655440000",
1301+ "abc123",
1302+ directory="/path/to/project",
1303+ )
1304+ """
1305+ if not _validate_uuid (session_id ):
1306+ return []
1307+ if not agent_id :
1308+ return []
1309+
1310+ subagents_dir = _resolve_subagents_dir (session_id , directory )
1311+ if subagents_dir is None :
1312+ return []
1313+
1314+ # The agent file may be directly in subagents/ or in a nested
1315+ # subdirectory — scan to find it.
1316+ match : Path | None = None
1317+ for found_id , file_path in _collect_agent_files (subagents_dir ):
1318+ if found_id == agent_id :
1319+ match = file_path
1320+ break
1321+ if match is None :
1322+ return []
1323+
1324+ try :
1325+ content = match .read_text (encoding = "utf-8" )
1326+ except OSError :
1327+ return []
1328+ if not content :
1329+ return []
1330+
1331+ entries = _parse_transcript_entries (content )
1332+ chain = _build_subagent_chain (entries )
1333+ messages = [
1334+ _to_session_message (e ) for e in chain if e .get ("type" ) in ("user" , "assistant" )
1335+ ]
1336+
1337+ if limit is not None and limit > 0 :
1338+ return messages [offset : offset + limit ]
1339+ if offset > 0 :
1340+ return messages [offset :]
1341+ return messages
0 commit comments