chsum 3.0.2__tar.gz → 3.0.3__tar.gz

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.
@@ -1,3 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: chsum
3
+ Version: 3.0.3
4
+ Summary: Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented.
5
+ Author: InDate
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: claude,claude-code,context,transcripts,work-log
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
1
12
  # chsum
2
13
 
3
14
  Work logs and reload-ready context from your coding-agent conversations —
@@ -452,8 +463,12 @@ chsum digest <ch_ref> --messages --stdout # print it rather than write it
452
463
  chsum digest <ch_ref> --messages > out.md # or your own path
453
464
  ```
454
465
 
455
- All three print in timestamp order across the transcript and its sidecars, with
456
- nothing filtered, deduplicated or collapsed. `--tools` and `--commands` print one
466
+ All three print in timestamp order across the transcript and its sidecars.
467
+ Three kinds of record are removed or merged, each because it repeats a row
468
+ already printed or carries only harness text: a record holding nothing but a
469
+ `<system-reminder>`, the harness's retry after a malformed tool call, and the
470
+ task notification that points at a report delivered as a peer message — its
471
+ status and usage move onto that report's row. `--tools` and `--commands` print one
457
472
  line per row — the call id, a `<session>:<line>` locator, local time, the tool
458
473
  name and the first line of the call. A row runs long and lets the terminal
459
474
  soft-wrap it rather than folding at a space: a command broken across lines can
@@ -465,6 +480,21 @@ cannot be used for. There is no flag or size limit behind that: the turn numbers
465
480
  below already select the part of a conversation you want, and a second way to
466
481
  ask for less would only be a worse one.
467
482
 
483
+ A message row is labelled by the sender its record names in `origin`, so a
484
+ report or an instruction another sender wrote never reads as yours:
485
+
486
+ ```
487
+ - `410bfb1a:1157` 19:09:15 agent a3d9a4a28f0836307 returned · completed · 54k tokens · 7 tools · 56s
488
+ - `0d02851a:1026` 12:15:53 message from key-service-be
489
+ - `9cb6adfc/a29d93fd:216` 16:32:20 coordinator
490
+ ```
491
+
492
+ A subagent's report prints once, from the parent's peer message where the
493
+ parent holds one and from the agent's own handback call where the report
494
+ arrived as an attachment. Only what you typed opens a turn in the parent;
495
+ scoped to an agent, its caller's and its coordinator's messages open the
496
+ agent's turns.
497
+
468
498
  Between them it prints every call, each the same single clipped line `--tools`
469
499
  gives it. A turn headed `2 tool calls · 8 commands` states how many ran and
470
500
  names none of them, which leaves the work between two replies unreadable; the
@@ -486,7 +516,8 @@ prints one tool call and its captured output whole, which is where the text a
486
516
  clipped call row actually lives.
487
517
 
488
518
  `--agents` lists every subagent the session ran and each report it sent back,
489
- numbered where an agent returned more than once. A report is the agent's own
519
+ numbered where an agent returned more than once, each headed by the status and
520
+ usage of the stop that sent it. A report is the agent's own
490
521
  document and runs to thousands of characters, so it is clipped with the cut
491
522
  marked; the row it came from names where the whole text is.
492
523
 
@@ -1,14 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: chsum
3
- Version: 3.0.2
4
- Summary: Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented.
5
- Author: InDate
6
- License-Expression: MIT
7
- License-File: LICENSE
8
- Keywords: claude,claude-code,context,transcripts,work-log
9
- Requires-Python: >=3.10
10
- Description-Content-Type: text/markdown
11
-
12
1
  # chsum
13
2
 
14
3
  Work logs and reload-ready context from your coding-agent conversations —
@@ -463,8 +452,12 @@ chsum digest <ch_ref> --messages --stdout # print it rather than write it
463
452
  chsum digest <ch_ref> --messages > out.md # or your own path
464
453
  ```
465
454
 
466
- All three print in timestamp order across the transcript and its sidecars, with
467
- nothing filtered, deduplicated or collapsed. `--tools` and `--commands` print one
455
+ All three print in timestamp order across the transcript and its sidecars.
456
+ Three kinds of record are removed or merged, each because it repeats a row
457
+ already printed or carries only harness text: a record holding nothing but a
458
+ `<system-reminder>`, the harness's retry after a malformed tool call, and the
459
+ task notification that points at a report delivered as a peer message — its
460
+ status and usage move onto that report's row. `--tools` and `--commands` print one
468
461
  line per row — the call id, a `<session>:<line>` locator, local time, the tool
469
462
  name and the first line of the call. A row runs long and lets the terminal
470
463
  soft-wrap it rather than folding at a space: a command broken across lines can
@@ -476,6 +469,21 @@ cannot be used for. There is no flag or size limit behind that: the turn numbers
476
469
  below already select the part of a conversation you want, and a second way to
477
470
  ask for less would only be a worse one.
478
471
 
472
+ A message row is labelled by the sender its record names in `origin`, so a
473
+ report or an instruction another sender wrote never reads as yours:
474
+
475
+ ```
476
+ - `410bfb1a:1157` 19:09:15 agent a3d9a4a28f0836307 returned · completed · 54k tokens · 7 tools · 56s
477
+ - `0d02851a:1026` 12:15:53 message from key-service-be
478
+ - `9cb6adfc/a29d93fd:216` 16:32:20 coordinator
479
+ ```
480
+
481
+ A subagent's report prints once, from the parent's peer message where the
482
+ parent holds one and from the agent's own handback call where the report
483
+ arrived as an attachment. Only what you typed opens a turn in the parent;
484
+ scoped to an agent, its caller's and its coordinator's messages open the
485
+ agent's turns.
486
+
479
487
  Between them it prints every call, each the same single clipped line `--tools`
480
488
  gives it. A turn headed `2 tool calls · 8 commands` states how many ran and
481
489
  names none of them, which leaves the work between two replies unreadable; the
@@ -497,7 +505,8 @@ prints one tool call and its captured output whole, which is where the text a
497
505
  clipped call row actually lives.
498
506
 
499
507
  `--agents` lists every subagent the session ran and each report it sent back,
500
- numbered where an agent returned more than once. A report is the agent's own
508
+ numbered where an agent returned more than once, each headed by the status and
509
+ usage of the stop that sent it. A report is the agent's own
501
510
  document and runs to thousands of characters, so it is clipped with the cut
502
511
  marked; the row it came from names where the whole text is.
503
512
 
@@ -667,6 +667,9 @@ _NOISE_MARKERS = (
667
667
  _NOISE_PREFIXES = (
668
668
  "## Context Usage", # `/context` writes its report in as a user record
669
669
  "[Your previous response had no visible output",
670
+ "The previous response failed to produce a valid tool call",
671
+ "Another Claude session sent a message",
672
+ "The coordinator sent a message",
670
673
  )
671
674
 
672
675
 
@@ -679,6 +682,57 @@ def is_real_prompt(text: str) -> bool:
679
682
  return not any(m in t for m in _NOISE_MARKERS)
680
683
 
681
684
 
685
+ def _origin(rec: dict) -> str:
686
+ """Who put a user record into the conversation: `origin.kind`, or
687
+ `turnOrigin`, the later spelling of the same field. "" on a tool result and
688
+ on records older than both."""
689
+ origin = rec.get("origin")
690
+ if isinstance(origin, dict) and origin.get("kind"):
691
+ return str(origin["kind"])
692
+ return str(rec.get("turnOrigin") or "")
693
+
694
+
695
+ def _harness_sent(rec: dict) -> bool:
696
+ """A user record another sender wrote: a peer session, a coordinator, a task
697
+ notification. Records without the field fall through to the text rules."""
698
+ kind = _origin(rec)
699
+ return bool(kind) and kind != "human"
700
+
701
+
702
+ _HANDBACK_LEAD = "The report follows:\n"
703
+
704
+
705
+ def _hand_back(rec: dict) -> tuple[str, str] | None:
706
+ """(agent id, report) for a subagent's report delivered as a peer message.
707
+ The harness indents each report line by two spaces, which is removed here."""
708
+ origin = rec.get("origin")
709
+ if not (isinstance(origin, dict) and origin.get("kind") == "peer"
710
+ and origin.get("handback") and origin.get("from")):
711
+ return None
712
+ body = str(origin.get("body") or "")
713
+ _, lead, report = body.partition(_HANDBACK_LEAD)
714
+ report = report if lead else body
715
+ report = "\n".join(ln.removeprefix(" ") for ln in report.splitlines()).strip()
716
+ return str(origin["from"]), report
717
+
718
+
719
+ _COORDINATOR_LEAD = re.compile(r"^The coordinator sent a message[^:]*:\s*")
720
+
721
+
722
+ def _sent_message(rec: dict, text: str) -> tuple[str, str] | None:
723
+ """(label, text) for a message another sender put in the conversation: a
724
+ peer session, by the name it sent under, or a coordinator. The harness's
725
+ lead-in is dropped, which leaves the message as its sender wrote it."""
726
+ origin = rec.get("origin")
727
+ kind = _origin(rec)
728
+ if kind == "peer" and isinstance(origin, dict) and origin.get("body"):
729
+ who = origin.get("name") or origin.get("from") or "peer"
730
+ return f"message from {who}", str(origin["body"]).strip()
731
+ if kind == "coordinator":
732
+ return "coordinator", _COORDINATOR_LEAD.sub("", text)
733
+ return None
734
+
735
+
682
736
  def is_typed_prompt(text: str) -> bool:
683
737
  """`is_real_prompt`, minus `!` runs — something you did, not something you
684
738
  said. Note paths stay on `is_real_prompt`, since notes are typed via `!`."""
@@ -1552,6 +1606,8 @@ def _is_typed_prompt(rec: dict, command_ids: frozenset[str] = frozenset()) -> bo
1552
1606
  (`_command_prompt_ids`) — without it a slash command's body still reads as typed."""
1553
1607
  if rec.get("isCompactSummary") or _tool_injected(rec, command_ids):
1554
1608
  return False # Claude Code's own auto-summary, or a loaded body — not typed
1609
+ if _harness_sent(rec):
1610
+ return False
1555
1611
  content = (rec.get("message") or {}).get("content")
1556
1612
  if isinstance(content, str):
1557
1613
  texts = [content]
@@ -2440,7 +2496,8 @@ def _drill_block(ref: str, path: pathlib.Path | None) -> list[str]:
2440
2496
  sides = subagent_transcripts(path)
2441
2497
  if sides:
2442
2498
  out.append("\nThe subagents it ran, each in its own file")
2443
- out += [f"- `{sc.stem.removeprefix('agent-')}` — `{sc}`" for sc in sides]
2499
+ parent = _split_agent_ref(ref)[0]
2500
+ out += [f"- `{parent}/{sc.stem.removeprefix('agent-')}` — `{sc}`" for sc in sides]
2444
2501
  out += ["\nTo read one line of it, put the number in place of `<line>`",
2445
2502
  f"- `sed -n '<line>p' {path} | jq`",
2446
2503
  "\nTo read it in full, in order",
@@ -3032,6 +3089,18 @@ _ROW_KINDS = {
3032
3089
  }
3033
3090
 
3034
3091
 
3092
+ # The tool a subagent returns its report through.
3093
+ _HANDBACK_TOOL = "SubagentHandback"
3094
+ _REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL)
3095
+
3096
+
3097
+ def _harness_reminder(rec: dict, text: str) -> bool:
3098
+ """A user record the harness wrote whole: `isMeta`, and nothing left once its
3099
+ `<system-reminder>` blocks are removed. `isMeta` alone also marks an image
3100
+ paste, which is typed text and stays a row."""
3101
+ return bool(rec.get("isMeta")) and not _REMINDER_RE.sub("", text).strip()
3102
+
3103
+
3035
3104
  def _short_id(tool_id: str) -> str:
3036
3105
  """Display form of a `tool_use` id. `toolu_` is on every one of them, so the
3037
3106
  prefix carries no information and costs six columns on every row."""
@@ -3045,6 +3114,12 @@ def collect_rows(path: pathlib.Path, kinds: tuple[str, ...],
3045
3114
  sections do all three, and these views are what they point at. `only_agent`
3046
3115
  narrows to one sidecar, for an agent ref."""
3047
3116
  out: list[_Row] = []
3117
+ # (agent, report) the parent holds as a peer message. The same report sits
3118
+ # in the agent's own handback call, and printing both repeats it. Keyed by
3119
+ # the report too: a return delivered as an attachment leaves no peer record,
3120
+ # and its handback call is then the only copy in any row.
3121
+ handed: set[tuple[str, str]] = set()
3122
+ stops: list[tuple[str, str, str]] = []
3048
3123
  for src, agent in mark_sources(path):
3049
3124
  if only_agent and agent != only_agent:
3050
3125
  continue
@@ -3066,9 +3141,19 @@ def collect_rows(path: pathlib.Path, kinds: tuple[str, ...],
3066
3141
  sidecars = _sidecar_ids(path)
3067
3142
  for lineno, rec in parsed:
3068
3143
  role = rec.get("type")
3144
+ ts = str(rec.get("timestamp") or "")
3145
+ # A notification delivered as an attachment leaves its text only in
3146
+ # the queue record, which is where most handback stops are found.
3147
+ body = rec.get("content")
3148
+ if (role == "queue-operation" and rec.get("operation") == "enqueue"
3149
+ and isinstance(body, str)
3150
+ and _is_notification(body)
3151
+ and _points_to_handback(body)
3152
+ and (aid := _agent_return(body, names, sidecars))):
3153
+ stops.append((aid, ts, _stop_note(body)))
3154
+ continue
3069
3155
  if role not in ("user", "assistant"):
3070
3156
  continue
3071
- ts = str(rec.get("timestamp") or "")
3072
3157
  content = (rec.get("message") or {}).get("content")
3073
3158
  texts: list[str] = []
3074
3159
  if isinstance(content, str):
@@ -3081,6 +3166,15 @@ def collect_rows(path: pathlib.Path, kinds: tuple[str, ...],
3081
3166
  texts.append(part.get("text", ""))
3082
3167
  elif part.get("type") == "tool_use":
3083
3168
  name = str(part.get("name") or "?")
3169
+ report = (part.get("input") or {}).get("message")
3170
+ # The handback call's `message` is the agent's report and
3171
+ # the only copy of it in any user or assistant record: the
3172
+ # parent receives it as an attachment, which no row reads.
3173
+ if (name == _HANDBACK_TOOL and "message" in kinds
3174
+ and isinstance(report, str) and report.strip()):
3175
+ out.append(_Row(ts, agent, "message", "", report.strip(),
3176
+ f"agent {agent} returned", lineno, src))
3177
+ continue
3084
3178
  cmd = (part.get("input") or {}).get("command")
3085
3179
  bash = name == "Bash" and isinstance(cmd, str) and cmd.strip()
3086
3180
  kind = "command" if bash else "tool"
@@ -3094,6 +3188,8 @@ def collect_rows(path: pathlib.Path, kinds: tuple[str, ...],
3094
3188
  if role == "user" and _tool_injected(rec, command_ids):
3095
3189
  continue
3096
3190
  text = "\n".join(t for t in texts if t.strip()).strip()
3191
+ if role == "user" and _harness_reminder(rec, text):
3192
+ continue
3097
3193
  # An answer to the question tool carries its text in a structured
3098
3194
  # field and none in the body, so the view dropped it and a window
3099
3195
  # numbered by turns would skip the turn a menu choice settled.
@@ -3112,25 +3208,49 @@ def collect_rows(path: pathlib.Path, kinds: tuple[str, ...],
3112
3208
  # it, and dropping it would leave the run with no turn 1 at all.
3113
3209
  starts = bool(role == "user" and (not agent or only_agent)
3114
3210
  and not rec.get("isCompactSummary")
3211
+ and not _harness_sent(rec)
3115
3212
  and (answered or is_typed_prompt(text)
3116
3213
  or (only_agent and not started)))
3117
3214
  returned = ""
3118
- if role == "user" and text.lstrip().startswith("<task-notification>"):
3215
+ if role == "user" and _is_notification(text):
3119
3216
  aid = _agent_return(text, names, sidecars)
3217
+ if aid and _points_to_handback(text):
3218
+ stops.append((aid, ts, _stop_note(text)))
3219
+ continue
3120
3220
  got = _task_fields(text)
3121
3221
  via = names.get(got["tool-use-id"], "")
3122
3222
  returned = (f"agent {aid} returned" if aid
3123
3223
  else f"{via or 'task'} {got['task-id'] or '?'} finished")
3124
3224
  text = _report_text(text)
3125
3225
  starts = False
3226
+ if role == "user" and (hand := _hand_back(rec)):
3227
+ handed.add(hand)
3228
+ returned = f"agent {hand[0]} returned"
3229
+ text = hand[1]
3230
+ starts = False
3231
+ elif role == "user" and (sent := _sent_message(rec, text)):
3232
+ returned, text = sent
3233
+ # Scoped to an agent, its caller's message is the agent's next
3234
+ # prompt, as the task it was spawned with is its first.
3235
+ starts = bool(only_agent)
3236
+ elif role == "user" and text.startswith(_NOISE_PREFIXES):
3237
+ continue
3126
3238
  if text:
3127
3239
  started = started or starts
3128
3240
  out.append(_Row(ts, agent, "message", "", text,
3129
3241
  returned or role, lineno, src, starts,
3130
3242
  asked=tur if answered else None))
3243
+ out = [r for r in out if not ((r.agent, r.text) in handed and r.kind == "message"
3244
+ and r.label == f"agent {r.agent} returned")]
3131
3245
  # A parent's line numbers and a sidecar's don't order against each other;
3132
3246
  # only a clock does. Same reason `mark_sources`' readers sort by timestamp.
3133
3247
  out.sort(key=lambda r: r.when)
3248
+ out = _fold_stops(
3249
+ out, stops,
3250
+ lambda r: (r.label.removeprefix("agent ").removesuffix(" returned")
3251
+ if r.kind == "message" and r.label.endswith(" returned") else ""),
3252
+ lambda r: r.when,
3253
+ lambda r, note: dataclasses.replace(r, label=f"{r.label} · {note}"))
3134
3254
  TRACE.step("collect_rows", kinds=",".join(kinds), rows=len(out),
3135
3255
  agent=only_agent or "(all)",
3136
3256
  span=f"{out[0].when}→{out[-1].when}" if out else "—")
@@ -3189,6 +3309,17 @@ def _tool_names(parsed) -> dict[str, str]:
3189
3309
  return names
3190
3310
 
3191
3311
 
3312
+ # How a notification record opens: the bare tag, or the tag behind the harness's
3313
+ # `[SYSTEM NOTIFICATION - NOT USER INPUT]` preamble. Anchored at the start
3314
+ # either way, since a message quoting the tag mid-text is not a notification.
3315
+ _NOTIFICATION_LEADS = ("<task-notification>", "[SYSTEM NOTIFICATION")
3316
+
3317
+
3318
+ def _is_notification(text: str) -> bool:
3319
+ return (text.lstrip().startswith(_NOTIFICATION_LEADS)
3320
+ and "<task-notification>" in text)
3321
+
3322
+
3192
3323
  def _task_fields(text: str) -> dict[str, str]:
3193
3324
  return {f: (m.group(1).strip() if (m := r.search(text)) else "")
3194
3325
  for f, r in _TASK_FIELD_RE.items()}
@@ -3227,15 +3358,45 @@ class _AgentReport:
3227
3358
  status: str
3228
3359
  summary: str
3229
3360
  result: str
3361
+ # The line is in the agent's own transcript, not the parent's: the report
3362
+ # reached the parent only as an attachment, and its handback call is the
3363
+ # one record holding it.
3364
+ in_sidecar: bool = False
3365
+
3366
+
3367
+ def _handback_reports(path: pathlib.Path) -> list[_AgentReport]:
3368
+ """Every report an agent returned through its handback call, read from its
3369
+ own transcript."""
3370
+ out: list[_AgentReport] = []
3371
+ for side in subagent_transcripts(path):
3372
+ agent = side.stem.removeprefix("agent-")
3373
+ for lineno, raw in enumerate(side.read_text(errors="replace").splitlines(), 1):
3374
+ try:
3375
+ rec = json.loads(raw)
3376
+ except json.JSONDecodeError:
3377
+ continue
3378
+ content = (rec.get("message") or {}).get("content") if isinstance(rec, dict) else None
3379
+ for part in content if isinstance(content, list) else []:
3380
+ if not (isinstance(part, dict) and part.get("type") == "tool_use"
3381
+ and part.get("name") == _HANDBACK_TOOL):
3382
+ continue
3383
+ report = (part.get("input") or {}).get("message")
3384
+ if isinstance(report, str) and report.strip():
3385
+ out.append(_AgentReport(agent, str(rec.get("timestamp") or ""),
3386
+ lineno, "", "", report.strip(),
3387
+ in_sidecar=True))
3388
+ return out
3230
3389
 
3231
3390
 
3232
3391
  def _agent_reports(path: pathlib.Path) -> list[_AgentReport]:
3233
3392
  """Every subagent return recorded in a parent transcript, in order. The
3234
- `<task-notification>` record is where the report lands: an async agent's
3393
+ report lands in a `<task-notification>` record, or in a peer message where
3394
+ the agent returned it through its handback call: an async agent's
3235
3395
  `tool_result` carries launch metadata, not the work."""
3236
3396
  out: list[_AgentReport] = []
3237
3397
  names: dict[str, str] = {}
3238
3398
  sidecars = _sidecar_ids(path)
3399
+ stops: list[tuple[str, str, str]] = []
3239
3400
  for lineno, raw in enumerate(path.read_text(errors="replace").splitlines(), 1):
3240
3401
  try:
3241
3402
  rec = json.loads(raw)
@@ -3244,6 +3405,10 @@ def _agent_reports(path: pathlib.Path) -> list[_AgentReport]:
3244
3405
  if not isinstance(rec, dict):
3245
3406
  continue
3246
3407
  names.update(_tool_names([rec]))
3408
+ if rec.get("type") == "user" and (hand := _hand_back(rec)):
3409
+ out.append((True, _AgentReport(hand[0], str(rec.get("timestamp") or ""),
3410
+ lineno, "", "", hand[1])))
3411
+ continue
3247
3412
  content = (rec.get("message") or {}).get("content")
3248
3413
  texts = ([content] if isinstance(content, str)
3249
3414
  else [p.get("text", "") for p in content
@@ -3263,26 +3428,80 @@ def _agent_reports(path: pathlib.Path) -> list[_AgentReport]:
3263
3428
  # notifications reads back as one otherwise — a 52,293-char paste
3264
3429
  # and an assistant message on the mechanism both matched a substring
3265
3430
  # test, and every one of them collapsed into a single nameless agent.
3266
- if not text.lstrip().startswith("<task-notification>"):
3431
+ if not _is_notification(text):
3267
3432
  continue
3268
3433
  if not _agent_return(text, names, sidecars):
3269
3434
  continue
3270
3435
  got = _task_fields(text)
3436
+ if _points_to_handback(text):
3437
+ stops.append((got["task-id"], str(rec.get("timestamp") or ""),
3438
+ _stop_note(text)))
3439
+ continue
3271
3440
  out.append((rec.get("type") == "user",
3272
3441
  _AgentReport(got["task-id"], str(rec.get("timestamp") or ""),
3273
3442
  lineno, got["status"], got["summary"],
3274
3443
  got["result"])))
3444
+ # Undelivered, so a peer message holding the same report wins the key below.
3445
+ out += [(False, r) for r in _handback_reports(path)]
3275
3446
  kept: dict[tuple, _AgentReport] = {}
3276
3447
  for delivered, rep in out:
3277
3448
  key = (rep.agent, rep.status, rep.result)
3278
3449
  if delivered or key not in kept:
3279
3450
  kept[key] = rep
3280
- reports = sorted(kept.values(), key=lambda r: r.line)
3451
+ # By time: a sidecar's line numbers don't order against the parent's.
3452
+ reports = sorted(kept.values(), key=lambda r: (r.when, r.line))
3453
+ # A notification enqueued and then delivered is the same stop twice.
3454
+ stops = list(dict.fromkeys(stops))
3455
+ reports = _fold_stops(
3456
+ reports, [s for s in stops if s[1]], lambda r: r.agent, lambda r: r.when,
3457
+ lambda r, note: dataclasses.replace(r, status=note) if not r.status else r)
3281
3458
  TRACE.step("_agent_reports", reports=len(reports), records=len(out),
3282
3459
  agents=len({r.agent for r in reports}))
3283
3460
  return reports
3284
3461
 
3285
3462
 
3463
+ # The `<result>` of a notification sent after a handback call. The report sits
3464
+ # in the peer message before it, so this notification adds only the stop's
3465
+ # status and usage, which are moved onto that report's row.
3466
+ _HANDBACK_POINTER = "This agent's report was delivered to you as a message from"
3467
+ _USAGE_RE = {f: re.compile(rf"<{f}>(\d+)</{f}>")
3468
+ for f in ("subagent_tokens", "tool_uses", "duration_ms")}
3469
+
3470
+
3471
+ def _stop_note(text: str) -> str:
3472
+ """`completed · 54k tokens · 7 tools · 57s` out of a `<task-notification>`,
3473
+ each part present only where the notification carries it."""
3474
+ got = {f: int(m.group(1)) for f, r in _USAGE_RE.items() if (m := r.search(text))}
3475
+ parts = [_task_fields(text)["status"]]
3476
+ if "subagent_tokens" in got:
3477
+ parts.append(f"{got['subagent_tokens'] / 1000:.0f}k tokens")
3478
+ if "tool_uses" in got:
3479
+ parts.append(_plural(got["tool_uses"], "tool"))
3480
+ if "duration_ms" in got:
3481
+ parts.append(_fmt_secs(got["duration_ms"] // 1000))
3482
+ return " · ".join(p for p in parts if p)
3483
+
3484
+
3485
+ def _points_to_handback(text: str) -> bool:
3486
+ return _task_fields(text)["result"].startswith(_HANDBACK_POINTER)
3487
+
3488
+
3489
+ def _fold_stops(rows: list, stops: list[tuple[str, str, str]], agent_of, when_of,
3490
+ note) -> list:
3491
+ """Each stop — (agent, time, note) of a notification that points at a
3492
+ handback — written onto the latest report of that agent at or before its
3493
+ time, through `note(row, text)`. A stop with no such report stays out:
3494
+ the notification carried nothing but the pointer and its usage."""
3495
+ rows = list(rows)
3496
+ for agent, when, text in stops:
3497
+ at = max((i for i, r in enumerate(rows)
3498
+ if agent_of(r) == agent and when_of(r) <= when), default=None,
3499
+ key=lambda i: when_of(rows[i]))
3500
+ if at is not None:
3501
+ rows[at] = note(rows[at], text)
3502
+ return rows
3503
+
3504
+
3286
3505
  def _report_text(text: str) -> str:
3287
3506
  """The report out of a `<task-notification>`, clipped. Falls back to the
3288
3507
  one-line summary where the notification carries no result."""
@@ -3325,7 +3544,8 @@ def render_agents(meta: Meta, ref: str, reports: list[_AgentReport],
3325
3544
  out.append("*No report recorded.*\n")
3326
3545
  continue
3327
3546
  for i, rep in enumerate(runs, 1):
3328
- head = f"`{meta.uuid[:8]}:{rep.line}` {_hhmmss(rep.when)}"
3547
+ who = f"{meta.uuid[:8]}/{rep.agent[:8]}" if rep.in_sidecar else meta.uuid[:8]
3548
+ head = f"`{who}:{rep.line}` {_hhmmss(rep.when)}"
3329
3549
  if len(runs) > 1:
3330
3550
  head = f"**Return {i} of {len(runs)}** — {head}"
3331
3551
  out.append(f"{head} · {rep.status or 'status not recorded'}\n")
@@ -4829,6 +5049,8 @@ def _failed(body: str, is_error: bool) -> bool:
4829
5049
  def _typed_text(rec: dict) -> str:
4830
5050
  """Only the parts you typed. The harness rides `<system-reminder>` blocks in
4831
5051
  the same record as a prompt, and `_record_text` would quote them with it."""
5052
+ if _harness_sent(rec):
5053
+ return ""
4832
5054
  content = (rec.get("message") or {}).get("content")
4833
5055
  if isinstance(content, str):
4834
5056
  texts = [content]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "chsum"
7
- version = "3.0.2"
7
+ version = "3.0.3"
8
8
  description = "Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,190 @@
1
+ """A subagent's report, from the call that returns it to the rows that print it.
2
+
3
+ A report travels in up to three records: the agent's handback call in its own
4
+ transcript, a peer message in the parent where the harness delivered it as a
5
+ user record, and a task notification whose result points back at the peer
6
+ message. The parent keeps the peer message only for some returns — the rest
7
+ arrive as attachments, which no row reads — so each case below names which copy
8
+ a row is drawn from.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import pathlib
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+
18
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
19
+
20
+ from chsum import core # noqa: E402
21
+
22
+ AGENT = "a1b2c3d4e5f6a7b8c"
23
+ SPAWN = "toolu_01Spawn"
24
+ PREAMBLE = ("[Subagent hand-back] The text below is the final report of a subagent "
25
+ "this session delegated to. The report follows:\n")
26
+
27
+
28
+ def _user(ts: str, content, **extra) -> dict:
29
+ return {"type": "user", "timestamp": ts, "message": {"role": "user", "content": content},
30
+ **extra}
31
+
32
+
33
+ def _handback(ts: str, report: str) -> dict:
34
+ return {"type": "assistant", "timestamp": ts, "message": {"role": "assistant", "content": [
35
+ {"type": "tool_use", "id": f"toolu_{ts[-6:-1]}", "name": "SubagentHandback",
36
+ "input": {"message": report}}]}}
37
+
38
+
39
+ def _pointer(tokens: int, tools: int, ms: int) -> str:
40
+ return (f"<task-notification>\n<task-id>{AGENT}</task-id>\n"
41
+ f"<tool-use-id>{SPAWN}</tool-use-id>\n<status>completed</status>\n"
42
+ f"<summary>Agent \"review\" finished</summary>\n"
43
+ f"<result>This agent's report was delivered to you as a message from "
44
+ f"\"{AGENT}\" (its SubagentHandback call). Read it there; it is not "
45
+ f"repeated here.\n</result>\n<usage><subagent_tokens>{tokens}</subagent_tokens>"
46
+ f"<tool_uses>{tools}</tool_uses><duration_ms>{ms}</duration_ms></usage>\n"
47
+ f"</task-notification>")
48
+
49
+
50
+ PARENT = [
51
+ _user("2026-09-25T04:00:00.000Z", "review the route", origin={"kind": "human"}),
52
+ {"type": "assistant", "timestamp": "2026-09-25T04:00:01.000Z", "message": {
53
+ "role": "assistant", "content": [{"type": "tool_use", "id": SPAWN, "name": "Agent",
54
+ "input": {"description": "review"}}]}},
55
+ _user("2026-09-25T04:00:02.000Z",
56
+ [{"type": "tool_result", "tool_use_id": SPAWN, "content": "launched"}]),
57
+ # The first return reached the parent as an attachment: its pointer is left
58
+ # in the queue record alone, and no peer message holds its report.
59
+ {"type": "queue-operation", "operation": "enqueue", "timestamp": "2026-09-25T04:02:00.000Z",
60
+ "content": _pointer(54154, 7, 56677)},
61
+ _user("2026-09-25T04:03:00.000Z", "thanks", origin={"kind": "human"}),
62
+ _user("2026-09-25T04:05:00.000Z",
63
+ f"Another Claude session sent a message:\n<agent-message from=\"{AGENT}\">\n"
64
+ f"{PREAMBLE} Second report.\n - indented once\n</agent-message>",
65
+ isMeta=True, turnOrigin="peer",
66
+ origin={"kind": "peer", "from": AGENT, "handback": True,
67
+ "body": f"{PREAMBLE} Second report.\n - indented once\n"}),
68
+ _user("2026-09-25T04:05:10.000Z", _pointer(72000, 11, 61000),
69
+ origin={"kind": "task-notification"}, turnOrigin="task_notification"),
70
+ _user("2026-09-25T04:05:20.000Z",
71
+ "The previous response failed to produce a valid tool call. Please retry "
72
+ "the tool call now.", isMeta=True),
73
+ ]
74
+
75
+ SIDECAR = [
76
+ _user("2026-09-25T04:00:03.000Z", "Review one route."),
77
+ _user("2026-09-25T04:00:04.000Z",
78
+ "<system-reminder>\nYour final report is delivered through SubagentHandback."
79
+ "\n</system-reminder>", isMeta=True),
80
+ _handback("2026-09-25T04:01:59.000Z", "First report."),
81
+ _handback("2026-09-25T04:04:59.000Z", "Second report.\n- indented once"),
82
+ ]
83
+
84
+
85
+ class Senders(unittest.TestCase):
86
+ """A message another sender put in an agent's conversation: labelled by its
87
+ sender, and opening the agent's next turn when the view is scoped to it."""
88
+
89
+ def setUp(self) -> None:
90
+ tmp = tempfile.TemporaryDirectory(prefix="chsum-test-")
91
+ self.addCleanup(tmp.cleanup)
92
+ self.path = pathlib.Path(tmp.name) / "sess.jsonl"
93
+ self.path.write_text(json.dumps(_user("2026-09-25T04:00:00.000Z", "go",
94
+ origin={"kind": "human"})) + "\n")
95
+ side = self.path.parent / self.path.stem / "subagents" / f"agent-{AGENT}.jsonl"
96
+ side.parent.mkdir(parents=True)
97
+ recs = [
98
+ _user("2026-09-25T04:00:01.000Z", "Research the question."),
99
+ _user("2026-09-25T04:01:00.000Z",
100
+ "Another Claude session sent a message while you were working:\n"
101
+ "<agent-message from=\"caller\">\nWrap up now.\n</agent-message>",
102
+ isMeta=True, origin={"kind": "peer", "from": "caller",
103
+ "name": "general-purpose", "body": "Wrap up now.\n"}),
104
+ _user("2026-09-25T04:02:00.000Z",
105
+ "The coordinator sent a message while you were working: Change of "
106
+ "direction.", isMeta=True, origin={"kind": "coordinator"}),
107
+ _user("2026-09-25T04:03:00.000Z",
108
+ "[SYSTEM NOTIFICATION - NOT USER INPUT]\nThis is an automated event.\n\n"
109
+ "<task-notification>\n<task-id>nested01</task-id>\n"
110
+ "<tool-use-id>toolu_01Nested</tool-use-id>\n<status>completed</status>\n"
111
+ "<result>Nested report.</result>\n</task-notification>",
112
+ isMeta=True, origin={"kind": "task-notification"}),
113
+ ]
114
+ recs.insert(1, {"type": "assistant", "timestamp": "2026-09-25T04:00:02.000Z",
115
+ "message": {"role": "assistant", "content": [
116
+ {"type": "tool_use", "id": "toolu_01Nested", "name": "Agent",
117
+ "input": {"description": "nested"}}]}})
118
+ side.write_text("".join(json.dumps(r) + "\n" for r in recs))
119
+
120
+ def test_each_sender_is_named_and_opens_the_agents_turn(self):
121
+ rows = [(r.label, r.text, r.starts_turn)
122
+ for r in core.collect_rows(self.path, core._ROW_KINDS["messages"], AGENT)
123
+ if r.kind == "message"]
124
+ self.assertEqual(rows, [
125
+ ("user", "Research the question.", True),
126
+ ("message from general-purpose", "Wrap up now.", True),
127
+ ("coordinator", "Change of direction.", True),
128
+ ("agent nested01 returned", "Nested report.", False),
129
+ ])
130
+
131
+
132
+ class Handbacks(unittest.TestCase):
133
+
134
+ def setUp(self) -> None:
135
+ tmp = tempfile.TemporaryDirectory(prefix="chsum-test-")
136
+ self.addCleanup(tmp.cleanup)
137
+ self.path = pathlib.Path(tmp.name) / "sess.jsonl"
138
+ side = self.path.parent / self.path.stem / "subagents" / f"agent-{AGENT}.jsonl"
139
+ side.parent.mkdir(parents=True)
140
+ for path, recs in ((self.path, PARENT), (side, SIDECAR)):
141
+ path.write_text("".join(json.dumps(r) + "\n" for r in recs))
142
+
143
+ def rows(self, only_agent: str = "") -> list:
144
+ return [r for r in core.collect_rows(self.path, core._ROW_KINDS["messages"],
145
+ only_agent)
146
+ if r.kind == "message"]
147
+
148
+ def test_each_report_prints_once_with_its_stop(self):
149
+ returned = [(r.agent, r.text, r.label) for r in self.rows()
150
+ if r.label.startswith("agent ")]
151
+ self.assertEqual(returned, [
152
+ (AGENT, "First report.",
153
+ f"agent {AGENT} returned · completed · 54k tokens · 7 tools · 56s"),
154
+ ("", "Second report.\n- indented once",
155
+ f"agent {AGENT} returned · completed · 72k tokens · 11 tools · 1m"),
156
+ ])
157
+
158
+ def test_no_row_carries_harness_text(self):
159
+ texts = "\n".join(r.text for r in self.rows())
160
+ for noise in ("system-reminder", "Another Claude session", "delivered to you",
161
+ "failed to produce a valid tool call"):
162
+ self.assertNotIn(noise, texts)
163
+
164
+ def test_scoped_to_the_agent_its_own_reports_stay(self):
165
+ returned = [r.text for r in self.rows(AGENT) if r.label.startswith("agent ")]
166
+ self.assertEqual(returned, ["First report.", "Second report.\n- indented once"])
167
+
168
+ def test_only_typed_prompts_open_a_turn(self):
169
+ self.assertEqual([r.text for r in self.rows() if r.starts_turn],
170
+ ["review the route", "thanks"])
171
+
172
+ def test_the_anchor_is_the_last_typed_prompt(self):
173
+ line, _ = core._last_prompt(self.path)
174
+ self.assertEqual(line, 5)
175
+
176
+ def test_agents_view_lists_every_return_with_its_source(self):
177
+ got = [(r.result, r.in_sidecar, r.status) for r in core._agent_reports(self.path)]
178
+ self.assertEqual(got, [
179
+ ("First report.", True, "completed · 54k tokens · 7 tools · 56s"),
180
+ ("Second report.\n- indented once", False,
181
+ "completed · 72k tokens · 11 tools · 1m"),
182
+ ])
183
+
184
+ def test_drill_block_names_each_agent_by_its_whole_ref(self):
185
+ block = "\n".join(core._drill_block("ch_0123", self.path))
186
+ self.assertIn(f"- `ch_0123/{AGENT}` — ", block)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes