system-diff 0.4.0__tar.gz → 0.4.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.
Files changed (56) hide show
  1. {system_diff-0.4.0 → system_diff-0.4.3}/PKG-INFO +9 -3
  2. {system_diff-0.4.0 → system_diff-0.4.3}/README.md +8 -2
  3. {system_diff-0.4.0 → system_diff-0.4.3}/docs/diagrams/system.json +20 -11
  4. {system_diff-0.4.0 → system_diff-0.4.3}/docs/diagrams/system.svg +3 -1
  5. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/.claude-plugin/plugin.json +1 -1
  6. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/page.tsx +7 -4
  7. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/pane.test.tsx +12 -0
  8. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/register.tsx +19 -3
  9. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/types/index.d.ts +2 -0
  10. {system_diff-0.4.0 → system_diff-0.4.3}/pyproject.toml +1 -1
  11. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/__init__.py +1 -1
  12. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/agent.py +20 -14
  13. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/agents.py +76 -4
  14. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/canvas.py +17 -7
  15. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/cli.py +2 -1
  16. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/pane.py +14 -2
  17. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/panel.py +3 -2
  18. {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_design_and_pane.py +82 -1
  19. {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_terminal_and_learning.py +18 -6
  20. {system_diff-0.4.0 → system_diff-0.4.3}/.claude-plugin/marketplace.json +0 -0
  21. {system_diff-0.4.0 → system_diff-0.4.3}/.gitattributes +0 -0
  22. {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/ci.yml +0 -0
  23. {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/release.yml +0 -0
  24. {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/system-diff.yml +0 -0
  25. {system_diff-0.4.0 → system_diff-0.4.3}/.gitignore +0 -0
  26. {system_diff-0.4.0 → system_diff-0.4.3}/LICENSE +0 -0
  27. {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/hooks.json +0 -0
  28. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/__main__.py +0 -0
  29. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/activity.py +0 -0
  30. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/architecture.py +0 -0
  31. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/companion.py +0 -0
  32. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/compare.py +0 -0
  33. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/config.py +0 -0
  34. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/design.py +0 -0
  35. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/diff.py +0 -0
  36. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/extract.py +0 -0
  37. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/graph.py +0 -0
  38. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/inside.py +0 -0
  39. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/learn.py +0 -0
  40. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/mcp_server.py +0 -0
  41. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/narrate.py +0 -0
  42. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/pictures.py +0 -0
  43. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/png.py +0 -0
  44. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/profile.py +0 -0
  45. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/readme.py +0 -0
  46. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/sources.py +0 -0
  47. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/statusline.py +0 -0
  48. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/textmap.py +0 -0
  49. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/topics.py +0 -0
  50. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/tui.py +0 -0
  51. {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/watch.py +0 -0
  52. {system_diff-0.4.0 → system_diff-0.4.3}/system-diff.toml +0 -0
  53. {system_diff-0.4.0 → system_diff-0.4.3}/tests/conftest.py +0 -0
  54. {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_capture.py +0 -0
  55. {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_checkpoint_watch_mcp.py +0 -0
  56. {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_system_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: system-diff
3
- Version: 0.4.0
3
+ Version: 0.4.3
4
4
  Summary: Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs.
5
5
  Project-URL: Homepage, https://github.com/simplifiedmachine/system-diff
6
6
  Project-URL: Documentation, https://github.com/simplifiedmachine/system-diff/wiki
@@ -92,6 +92,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
92
92
  - **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
93
93
  back, `f` follows Claude again.
94
94
  - **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
95
+ - **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
96
+ tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
95
97
  - **This turn**: each change to the design, and what it trades off.
96
98
  - **Learn**: how the part Claude works on is designed.
97
99
  - The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
@@ -130,10 +132,12 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
130
132
  - **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
131
133
  depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
132
134
  ("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
133
- (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result:
134
- nobody waits on anybody.
135
+ (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
136
+ of any kind (a read, an edit, a shell command, a search), once: nobody waits on anybody.
135
137
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
136
138
  under Needs your attention.
139
+ - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
140
+ turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
137
141
 
138
142
  The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
139
143
  (`agents.json`, `bus.jsonl`), never pushed.
@@ -142,6 +146,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
142
146
 
143
147
  - **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
144
148
  /health · a new way in: it needs the same checks as the others". Edits that change no structure stay quiet.
149
+ - **Messages from other agents** on the bus arrive with an agent's next tool result of any kind, not only after
150
+ an edit.
145
151
  - **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
146
152
  structure stayed the same; nothing for a turn that only talked.
147
153
  - **Ask Claude** "what is the API part?" or "what does this change do?": the MCP server gives it `explain` (the facts
@@ -60,6 +60,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
60
60
  - **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
61
61
  back, `f` follows Claude again.
62
62
  - **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
63
+ - **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
64
+ tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
63
65
  - **This turn**: each change to the design, and what it trades off.
64
66
  - **Learn**: how the part Claude works on is designed.
65
67
  - The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
@@ -98,10 +100,12 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
98
100
  - **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
99
101
  depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
100
102
  ("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
101
- (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result:
102
- nobody waits on anybody.
103
+ (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
104
+ of any kind (a read, an edit, a shell command, a search), once: nobody waits on anybody.
103
105
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
104
106
  under Needs your attention.
107
+ - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
108
+ turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
105
109
 
106
110
  The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
107
111
  (`agents.json`, `bus.jsonl`), never pushed.
@@ -110,6 +114,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
110
114
 
111
115
  - **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
112
116
  /health · a new way in: it needs the same checks as the others". Edits that change no structure stay quiet.
117
+ - **Messages from other agents** on the bus arrive with an agent's next tool result of any kind, not only after
118
+ an edit.
113
119
  - **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
114
120
  structure stayed the same; nothing for a turn that only talked.
115
121
  - **Ask Claude** "what is the API part?" or "what does this change do?": the MCP server gives it `explain` (the facts
@@ -9,7 +9,7 @@
9
9
  "container": "",
10
10
  "declared": true,
11
11
  "files": 3,
12
- "lines": 313,
12
+ "lines": 314,
13
13
  "hub": false,
14
14
  "quiet": false
15
15
  },
@@ -33,7 +33,7 @@
33
33
  "container": "",
34
34
  "declared": true,
35
35
  "files": 1,
36
- "lines": 456,
36
+ "lines": 462,
37
37
  "hub": false,
38
38
  "quiet": false
39
39
  },
@@ -45,7 +45,7 @@
45
45
  "container": "",
46
46
  "declared": true,
47
47
  "files": 1,
48
- "lines": 296,
48
+ "lines": 368,
49
49
  "hub": false,
50
50
  "quiet": false
51
51
  },
@@ -69,7 +69,7 @@
69
69
  "container": "",
70
70
  "declared": true,
71
71
  "files": 1,
72
- "lines": 521,
72
+ "lines": 531,
73
73
  "hub": false,
74
74
  "quiet": false
75
75
  },
@@ -201,7 +201,7 @@
201
201
  "container": "",
202
202
  "declared": true,
203
203
  "files": 2,
204
- "lines": 732,
204
+ "lines": 745,
205
205
  "hub": false,
206
206
  "quiet": false
207
207
  },
@@ -317,8 +317,10 @@
317
317
  "agent>learn": 1,
318
318
  "agent>sources": 1,
319
319
  "agents>activity": 1,
320
- "agents>architecture": 1,
320
+ "agents>architecture": 2,
321
+ "agents>config": 1,
321
322
  "agents>design": 1,
323
+ "agents>extract": 1,
322
324
  "agents>sources": 1,
323
325
  "architecture>design": 2,
324
326
  "architecture>diff": 1,
@@ -433,7 +435,8 @@
433
435
  ],
434
436
  "agent>agents": [
435
437
  "ORCHESTRATE",
436
- "after_edit"
438
+ "after_edit",
439
+ "deliver"
437
440
  ],
438
441
  "agent>companion": [
439
442
  "add_vscode_task",
@@ -466,22 +469,28 @@
466
469
  ],
467
470
  "agents>activity": [
468
471
  "EDIT",
469
- "_replace",
470
472
  "PROMPT",
471
- "READ",
473
+ "_replace",
472
474
  "is_system",
473
- "part_of"
475
+ "part_of",
476
+ "READ"
474
477
  ],
475
478
  "agents>architecture": [
476
479
  "groups_of"
477
480
  ],
481
+ "agents>config": [
482
+ "lenient"
483
+ ],
478
484
  "agents>design": [
479
485
  "depends",
486
+ "kinds",
480
487
  "and_list",
481
488
  "fan_in",
482
- "kinds",
483
489
  "summarize"
484
490
  ],
491
+ "agents>extract": [
492
+ "Code"
493
+ ],
485
494
  "agents>sources": [
486
495
  "git_dir"
487
496
  ],
@@ -64,9 +64,11 @@ text { font-family: 'Segoe UI', FreeSans, 'Helvetica Neue', Arial, sans-serif; }
64
64
  <path class="be k-call s-same" d="M214,172 C243,172 243,188 272,188" marker-end="url(#ar-call-same)"><title>agent → extract</title></path>
65
65
  <path class="be k-call s-same" d="M214,172 C243,172 243,788 272,788" marker-end="url(#ar-call-same)"><title>agent → learn</title></path>
66
66
  <path class="be k-call s-same" d="M214,172 C246,172 246,99 278,99 L478,99 C507,99 507,52 536,52" marker-end="url(#ar-call-same)"><title>agent → sources</title></path>
67
- <path class="be k-call s-same" d="M478,488 C507,488 507,112 536,112" marker-end="url(#ar-call-same)"><title>agents → activity</title></path>
68
67
  <path class="be k-call s-same" d="M478,488 C514,488 514,428 484,428" marker-end="url(#ar-call-same)"><title>agents → architecture</title></path>
68
+ <path class="be k-call s-same" d="M478,488 C507,488 507,112 536,112" marker-end="url(#ar-call-same)"><title>agents → activity</title></path>
69
+ <path class="be k-call s-same" d="M478,488 C544,488 544,128 484,128" marker-end="url(#ar-call-same)"><title>agents → config</title></path>
69
70
  <path class="be k-call s-same" d="M478,488 C514,488 514,548 484,548" marker-end="url(#ar-call-same)"><title>agents → design</title></path>
71
+ <path class="be k-call s-same" d="M478,488 C544,488 544,188 484,188" marker-end="url(#ar-call-same)"><title>agents → extract</title></path>
70
72
  <path class="be k-call s-same" d="M478,488 C507,488 507,52 536,52" marker-end="url(#ar-call-same)"><title>agents → sources</title></path>
71
73
  <path class="be k-call s-same" d="M478,428 C524,428 524,548 484,548" marker-end="url(#ar-call-same)"><title>architecture → design</title></path>
72
74
  <path class="be k-call s-same" d="M478,428 C524,428 524,308 484,308" marker-end="url(#ar-call-same)"><title>architecture → graph</title></path>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "system-diff",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "A pane beside the chat: what Claude is working on in your system, zoomed in, what just happened and why it matters, and a topic to learn (system-diff)",
5
5
  "types": "./types/index.d.ts",
6
6
  "author": {
@@ -12,7 +12,7 @@ import type { Page, PageBlock, Row, Run } from '../types'
12
12
  // the rest keeps its colours: yellow what changed, green what is new, red what went, gray the background.
13
13
 
14
14
  type Props = { page: Page; canGoBack: boolean }
15
- type State = { sel: string; sx: number; mine: boolean; ticking?: boolean }
15
+ type State = { sel: string; sx: number; mine: boolean; ticking?: boolean; free?: boolean } // free: scrolled by hand
16
16
 
17
17
  const COLORS: Record<string, string> = {
18
18
  accent: 'blue', focus: 'blue', near: 'blue', data: 'blue',
@@ -88,7 +88,7 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
88
88
  const state = surface.state
89
89
  const sel = state?.mine && byId.has(state.sel) ? state.sel : byId.has(page.pick) ? page.pick : (page.blocks[0]?.id ?? '')
90
90
  const picked = byId.get(sel)
91
- const sx = across(page, cols, state?.mine ? state.sx : 0, picked)
91
+ const sx = across(page, cols, state?.mine ? state.sx : 0, state?.free ? undefined : picked)
92
92
 
93
93
  // The pane was laid out for another width (the split moved): ask for this one once it holds, not for every
94
94
  // width a drag passes on its way.
@@ -104,7 +104,8 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
104
104
  surface.post({ width: askedWidth })
105
105
  }
106
106
  })
107
- surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true })
107
+ surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true,
108
+ free: state?.free })
108
109
  }
109
110
 
110
111
  const pick = (b: PageBlock | undefined, x = sx) => {
@@ -127,8 +128,10 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
127
128
  } else if (['up', 'down', 'left', 'right'].includes(e.key)) {
128
129
  pick(move(page.blocks, picked, e.key))
129
130
  } else if (e.key === '[' || e.key === ']') {
130
- surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true,
131
+ surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true, free: true,
131
132
  ticking: true })
133
+ } else if (e.key === '-' || e.key === '=' || e.key === '+') {
134
+ surface.post({ zoomBy: e.key === '-' ? 1 : -1 }) // - shows more of the system, = bigger boxes
132
135
  } else if (e.key === 'return') {
133
136
  act(picked, 'enter')
134
137
  } else if (e.key === ' ') {
@@ -185,3 +185,15 @@ test('/system-diff close closes the pane, and so does its close button', async (
185
185
  expect(closed).toEqual(['system-diff', 'system-diff'])
186
186
  await ui.unmount()
187
187
  })
188
+
189
+ test('zoom out draws the diagrams compact, zoom in full again', async ($, on) => {
190
+ const asked: string[][] = []
191
+ answer(on, asked)
192
+ await $.command.run(RUN)
193
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
194
+ await ui.press({ key: 'zoom-out' })
195
+ expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 1')
196
+ await ui.key({ key: '=', in: 'page' })
197
+ expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 0')
198
+ await ui.unmount()
199
+ })
@@ -14,7 +14,7 @@ import type { Act, Page, Row } from '../types'
14
14
  // green what is new, done or to learn, white what to read, gray the background, red only what was taken away.
15
15
 
16
16
  const PANE = 'system-diff'
17
- const VERSION = '0.4.0' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
17
+ const VERSION = '0.4.3' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
18
18
  const WIDEST = 999 // as wide as Claude Code docks a pane: 45% of the terminal
19
19
  const COMMANDS = ['system-diff', 'system-diff-pane'] as const
20
20
  const VIEWS = [['now', 'Now', 'n'], ['map', 'Map', 'm'], ['agents', 'Agents', 'a'], ['changes', 'Changes', 'c'],
@@ -25,6 +25,9 @@ const view = atom({ plugin: 'system-diff', key: 'view' } as const, 'now', { shap
25
25
  const part = atom({ plugin: 'system-diff', key: 'part' } as const, '', { shape: 'part-1' })
26
26
  const back = atom({ plugin: 'system-diff', key: 'back' } as const, [] as string[], { shape: 'back-2' })
27
27
  const note = atom({ plugin: 'system-diff', key: 'note' } as const, 'Reading the system…', { shape: 'note-1' })
28
+ // How compact the diagrams are: -1 by the pane's width (compact below 110 columns), 0 full, 1 compact, 2 tiny.
29
+ const zoomLevel = atom({ plugin: 'system-diff', key: 'zoom' } as const, -1, { shape: 'zoom-1' })
30
+ const ZOOM_WORD = ['full', 'compact', 'tiny']
28
31
 
29
32
  // How to run system-diff: the repository's own virtualenv first, then whatever is on PATH.
30
33
  const RUNNERS: string[][] = [
@@ -73,6 +76,8 @@ async function refresh($: EngineInterface): Promise<void> {
73
76
  again = false
74
77
  const argv = ['panel', '--protocol', '2', '--width', String(columns), '--view', await read($, view),
75
78
  '--part', await read($, part)]
79
+ const level = await read($, zoomLevel)
80
+ if (level >= 0) argv.push('--zoom', String(level)) // left out, the engine picks by the width (older ones too)
76
81
  const got = await run($, argv)
77
82
  if (got && !got.old) {
78
83
  if (got.out !== last) {
@@ -155,6 +160,13 @@ async function scrollBy($: EngineInterface, rows: number): Promise<void> {
155
160
 
156
161
  // As wide as Claude Code docks a pane, the first time: a width the person set (dragged, or with the pane's keys:
157
162
  // ctrl+x ← →) is theirs from then on.
163
+ // More of the system in the pane (zoom out, +1) or bigger boxes (zoom in, -1), from where the page is drawn now.
164
+ async function zoomBy($: EngineInterface, by: number): Promise<void> {
165
+ const drawnAt = (await read($, page))?.zoom ?? 0
166
+ await update($, zoomLevel, () => Math.max(0, Math.min(2, drawnAt + by)))
167
+ await refresh($)
168
+ }
169
+
158
170
  function open($: EngineInterface) {
159
171
  return $.ui.open({ id: PANE, title: 'System diff', columns: WIDEST })
160
172
  }
@@ -213,7 +225,7 @@ export const register: Register = on => {
213
225
  // What the page (page.tsx) asks for.
214
226
  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
215
227
  const data = (e.data ?? {}) as Act & { back?: boolean; follow?: boolean; width?: number; reveal?: number;
216
- scrollBy?: number }
228
+ scrollBy?: number; zoomBy?: number }
217
229
  if (typeof data.width === 'number' && data.width > 0 && data.width !== columns) {
218
230
  columns = data.width
219
231
  await refresh($)
@@ -223,6 +235,7 @@ export const register: Register = on => {
223
235
  else if (typeof data.learn === 'string') await learn($, data.learn)
224
236
  else if (data.back) await goBack($)
225
237
  else if (data.follow) await follow($)
238
+ else if (typeof data.zoomBy === 'number') await zoomBy($, data.zoomBy)
226
239
  else if (typeof data.view === 'string') await choose($, data.view)
227
240
  return {}
228
241
  })
@@ -278,11 +291,14 @@ export const register: Register = on => {
278
291
  {behind.length > 0 && (
279
292
  <Button key="back" hotkey="b" plain onPress={() => void goBack($)}><Text dimColor>← Back</Text></Button>
280
293
  )}
294
+ <Button key="zoom-out" plain onPress={() => void zoomBy($, 1)}><Text dimColor>⊖</Text></Button>
295
+ <Text dimColor>{ZOOM_WORD[drawn?.zoom ?? 0] ?? 'full'}</Text>
296
+ <Button key="zoom-in" plain onPress={() => void zoomBy($, -1)}><Text dimColor>⊕</Text></Button>
281
297
  <Button key="close" plain onPress={() => void $.ui.close({ id: PANE })}><Text dimColor>× close</Text></Button>
282
298
  </Box>
283
299
  {said !== '' && <Text dimColor>{said}</Text>}
284
300
  {drawn !== null && Client !== undefined && (
285
- <Client key="page" module="./page.tsx" props={{ page: drawn, canGoBack: behind.length > 0 }} />
301
+ <Client key="page" module="./page.tsx" width="100%" props={{ page: drawn, canGoBack: behind.length > 0 }} />
286
302
  )}
287
303
  {drawn !== null && Client === undefined && drawn.rows.map(line)}
288
304
  {drawn !== null && Client === undefined && (
@@ -24,6 +24,7 @@ export type Page = {
24
24
  lights: Record<string, string[]>
25
25
  details: Record<string, Row[]>
26
26
  pick: string
27
+ zoom?: number // how compact it is drawn: 0 full, 1 compact, 2 tiny
27
28
  }
28
29
 
29
30
  declare module 'claude-code' {
@@ -34,6 +35,7 @@ declare module 'claude-code' {
34
35
  part: Shaped<string>
35
36
  back: Shaped<string[]>
36
37
  note: Shaped<string>
38
+ zoom: Shaped<number>
37
39
  }
38
40
  }
39
41
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "system-diff"
7
- version = "0.4.0"
7
+ version = "0.4.3"
8
8
  description = "Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,4 +5,4 @@
5
5
  bp = extract.extract(DirSource(Path(".")), cfg)
6
6
  svg = graph.system_svg(bp, cfg)
7
7
  """
8
- __version__ = "0.4.0"
8
+ __version__ = "0.4.3"
@@ -137,30 +137,36 @@ def on_prompt(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
137
137
 
138
138
 
139
139
  def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
140
- """Note the file and keep a brief of its part for the status line. After an edit to code, look at what the turn
141
- has done to the system so far, and say what this edit added to it (one hook at a time: they can overlap)."""
140
+ """After every tool: hand the agent what other agents published since its last tool. For a read or an edit of
141
+ a file in a part, also note the file and keep a brief of its part for the status line; after an edit to code,
142
+ look at what the turn has done to the system so far, and say what this edit added to it (one hook at a time:
143
+ they can overlap)."""
142
144
  tool = payload.get("tool_name", "")
145
+ agent_id = payload.get("agent_id") or ""
146
+
147
+ def unread() -> Dict[str, Any]:
148
+ return {"message": None, "context": agents.deliver(root, agent_id)}
149
+
143
150
  if tool not in EDIT_TOOLS + READ_TOOLS:
144
- return None
151
+ return unread()
145
152
  given = payload.get("tool_input") or {}
146
153
  path = activity.relative(root, given.get("file_path") or given.get("notebook_path") or "")
147
154
  if not path:
148
- return None
155
+ return unread()
149
156
  edit = tool in EDIT_TOOLS
150
- agent_id = payload.get("agent_id") or ""
151
157
  activity.record(root, {"event": activity.EDIT if edit else activity.READ, "path": path, "tool": tool,
152
158
  "session": payload.get("session_id", ""), "agent": agent_id})
153
159
  cfg = config.lenient(root)
154
160
  part = activity.part_of(path, Code(cfg))
155
161
  if part is None:
156
- return None
162
+ return unread()
157
163
  if not activity.load_brief(root, part, max_age=600):
158
164
  activity.save_brief(root, learn.brief(root, cfg, part))
159
165
  if not (edit and turn_sha(root)):
160
- return None
166
+ return unread()
161
167
  with activity.only_one(root, wait=10) as mine:
162
168
  if not mine:
163
- return None
169
+ return unread()
164
170
  last = activity.read_state(root).get("turn") or {}
165
171
  before = last.get("told") or [s["text"] for s in last.get("story") or []]
166
172
  since, turn = compare_worktree(root, ["checkpoint", "turn"])
@@ -246,12 +252,12 @@ HOOKS = ( # event, matcher, how it runs, what it does
246
252
  "WezTerm, kitty, Windows Terminal)"),
247
253
  ("UserPromptSubmit", None, {"timeout": 10},
248
254
  "marks where each turn starts, and asks Claude to plan the work for a team of agents"),
249
- ("PostToolUse", "|".join(READ_TOOLS), {"async": True},
250
- "notes the files Claude reads, for the terminal view and the status line (in the background)"),
251
- # Not in the background: only a hook Claude Code waits for can show you a line in the conversation. A timeout
252
- # (seconds) so a stuck git never holds up the agent; Claude Code's own default is 600.
253
- ("PostToolUse", "|".join(EDIT_TOOLS), {"timeout": 15},
254
- "after an edit that changes the system's structure, one line on what it changed and why it matters"),
255
+ # One entry for every tool, and not in the background: only a hook Claude Code waits for can show you a line in
256
+ # the conversation or hand the agent its messages. A timeout (seconds) so a stuck git never holds up the agent;
257
+ # Claude Code's own default is 600.
258
+ ("PostToolUse", None, {"timeout": 15},
259
+ "notes the files Claude reads and, after an edit that changes the system's structure, one line on what it "
260
+ "changed; and hands each agent what other agents published"),
255
261
  ("Stop", None, {"timeout": 30}, "after each turn, one line on what the turn changed in the system"),
256
262
  )
257
263
 
@@ -2,7 +2,7 @@
2
2
  CLAIMS, so agents keep to their own blocks; and a BUS, an async publish/subscribe channel whose topics are the system's
3
3
  blocks: an agent subscribes to the blocks its own depend on, every structural change to a block is published on that
4
4
  block's topic (by the hooks, as it happens), agents publish their own notes too, and each agent's unread messages
5
- reach it with its next tool call, without waiting on anyone.
5
+ reach it with its next tool result of any kind, without waiting on anyone.
6
6
 
7
7
  Who is who: Claude Code names each subagent by an id (`agent_id` in its hook events; the main conversation has none,
8
8
  "main" here). A plan names agents by area ("billing"); an id is tied to a name when it first edits a block that name
@@ -24,6 +24,7 @@ from .sources import git_dir
24
24
 
25
25
  STATE, BUS = "agents.json", "bus.jsonl"
26
26
  MAIN = "main"
27
+ SYSTEM = "system-diff" # the sender of what system-diff itself publishes
27
28
  KEEP = 2000 # messages kept on the bus
28
29
  MOST_AGENTS = 4 # a plan's agents at most, unless asked for more
29
30
 
@@ -33,10 +34,11 @@ def _load(root: Path) -> Dict[str, Any]:
33
34
  data = json.loads((git_dir(root) / STATE).read_text(encoding="utf-8"))
34
35
  if isinstance(data, dict):
35
36
  return {"plan": data.get("plan") or {}, "claims": data.get("claims") or {},
36
- "agents": data.get("agents") or {}, "ids": data.get("ids") or {}}
37
+ "agents": data.get("agents") or {}, "ids": data.get("ids") or {},
38
+ "warned": data.get("warned") or {}}
37
39
  except (OSError, ValueError):
38
40
  pass
39
- return {"plan": {}, "claims": {}, "agents": {}, "ids": {}}
41
+ return {"plan": {}, "claims": {}, "agents": {}, "ids": {}, "warned": {}}
40
42
 
41
43
 
42
44
  def _save(root: Path, data: Dict[str, Any]) -> None:
@@ -63,6 +65,10 @@ def tie(root: Path, agent_id: str, name: str) -> None:
63
65
  data = _load(root)
64
66
  if data["ids"].get(agent_id) != name:
65
67
  data["ids"][agent_id] = name
68
+ short = data["agents"].get("agent-" + agent_id[:6]) # what it read before it had a name stays read
69
+ if short:
70
+ a = _agent(data, name)
71
+ a["read"] = max(a["read"], short["read"])
66
72
  _save(root, data)
67
73
 
68
74
 
@@ -161,6 +167,20 @@ def as_context(msgs: List[Dict[str, Any]], most: int = 8) -> str:
161
167
 
162
168
  # ── what the hooks do for each agent ─────────────────────────────────────────
163
169
 
170
+ def _for_context(msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
171
+ """The messages worth putting in an agent's context: spread warnings reach the agent they are about directly; on
172
+ the bus they are a record for the inbox tool."""
173
+ return [m for m in msgs if not (m.get("from") == SYSTEM and m.get("kind") == "warning")]
174
+
175
+
176
+ def deliver(root: Path, agent_id: str) -> str:
177
+ """The unread messages for the agent with this Claude Code id ("" or "main": the main conversation), as context
178
+ text, marked read; "" without writing anything when there is no bus yet."""
179
+ if not (git_dir(root) / BUS).exists():
180
+ return ""
181
+ return as_context(_for_context(inbox(root, name_of(root, agent_id))))
182
+
183
+
164
184
  def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[str, Any]], bp: dict) -> Dict[str, str]:
165
185
  """After an agent's edit: tie it to a plan name if it edited a claimed block nobody else is tied to, subscribe it
166
186
  to its block and the blocks that block depends on, publish what the edit changed in the structure on the topics
@@ -182,7 +202,59 @@ def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[
182
202
  if part and claimed and claimed != name:
183
203
  warn = f"{part} is claimed by agent {claimed}; {name} just edited it"
184
204
  publish(root, f"agent:{claimed}", f"{name} edited your block {part}", sender=name, kind="warning")
185
- return {"context": as_context(inbox(root, name)), "warn": warn, "name": name}
205
+ from . import config
206
+ wide = spread(root, name, agent_id, bp, config.lenient(root))
207
+ unread = _for_context(inbox(root, name))
208
+ context = "\n".join(t for t in (as_context(unread), wide) if t)
209
+ warn = "; ".join(t for t in (warn, wide) if t)
210
+ return {"context": context, "warn": warn, "name": name}
211
+
212
+
213
+ def areas_of(root: Path, bp: dict, cfg: Dict[str, Any]) -> Dict[str, str]:
214
+ """Each block's area: the agent the saved plan gave it to, when there is a plan; else its container, or the
215
+ block itself when it is in none."""
216
+ agents = (_load(root)["plan"] or {}).get("agents") or []
217
+ if agents:
218
+ return {b: g["name"] for g in agents for b in g.get("blocks") or []}
219
+ from .architecture import groups_of
220
+ group = groups_of(bp, cfg, design.kinds(bp, cfg))
221
+ return {cid: (group[cid][2:] if group.get(cid, "").startswith("g:") else cid) for cid in bp["components"]}
222
+
223
+
224
+ def spread(root: Path, name: str, agent_id: str, bp: dict, cfg: Dict[str, Any]) -> str:
225
+ """When one agent has edited blocks in two or more areas this turn: a message telling it to plan a team and hand
226
+ the rest on, once per agent per turn (also published to all); else ""."""
227
+ from .extract import Code
228
+ code = Code(cfg)
229
+ me = "" if agent_id in ("", None, MAIN) else agent_id
230
+ start, parts = 0.0, []
231
+ for e in activity.events(root, 400):
232
+ if e.get("event") == activity.PROMPT and not activity.is_system(e.get("text", "")):
233
+ start, parts = e["at"], []
234
+ elif e.get("event") == activity.EDIT and (e.get("agent") or "") == me and e.get("path"):
235
+ part = activity.part_of(e["path"], code)
236
+ if part and part not in parts:
237
+ parts.append(part)
238
+ area = areas_of(root, bp, cfg)
239
+ seen: List[str] = []
240
+ for p in parts:
241
+ a = area.get(p, p)
242
+ if a not in seen:
243
+ seen.append(a)
244
+ if len(seen) < 2:
245
+ return ""
246
+ data = _load(root)
247
+ turn = str(start)
248
+ told = data["warned"].get(turn) or []
249
+ if name in told:
250
+ return ""
251
+ data["warned"] = {turn: told + [name]} # only the latest turn is kept
252
+ _save(root, data)
253
+ text = (f"{name} has edited blocks in {len(seen)} areas this turn ({', '.join(seen)}). system-diff runs work "
254
+ "that spans areas as a team: call plan_agents with what is left, launch its agents in parallel, and "
255
+ "keep to one area yourself.")
256
+ publish(root, "all", text, sender=SYSTEM, kind="warning")
257
+ return text
186
258
 
187
259
 
188
260
  def who_worked(events: Iterable[dict], code) -> Dict[str, Dict[str, Any]]:
@@ -138,8 +138,12 @@ def title_style(n: dict) -> str:
138
138
  return f"title bold {st}" if st != "same" else "title"
139
139
 
140
140
 
141
+ ZOOMS = {0: (20, 32, 2, 6, 4), 1: (12, 22, 0, 5, 3), 2: (8, 16, 0, 5, 3)} # min box, max box, rows, channel, gap
142
+ # by zoom: full, compact, tiny
143
+
144
+
141
145
  def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]], *, width: int = 100,
142
- centre: bool = False, most_lines: int = 2) -> Grid:
146
+ centre: bool = False, most_lines: int = 2, zoom: int = 0) -> Grid:
143
147
  """A system diagram: `nodes` (id, col, title, sub, status, type: actor|component|group|store|host; `agent` where
144
148
  the agent works, `focus` for the box zoomed in on, `items` for the components a container holds, `lines` for
145
149
  rows inside a box) in `columns` order, joined by `edges` (from, to, kind, status, label; `from_item`/`to_item`:
@@ -150,6 +154,12 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
150
154
  present = [k for k, _h in columns if any(n["col"] == k for n in nodes)]
151
155
  if not present:
152
156
  return g
157
+ least, most_w, rows_in, chan_min, chan_pad = ZOOMS.get(zoom, ZOOMS[0])
158
+ most_lines = min(most_lines, rows_in)
159
+ if zoom: # compact: no labels on flows (a picked box's detail names them), tiny: no subtitles either
160
+ edges = [{**e, "label": ""} for e in edges]
161
+ if zoom >= 2:
162
+ nodes = [{**n, "sub": ""} for n in nodes]
153
163
  heading = {k: h for k, h in columns}
154
164
  ci = {k: i for i, k in enumerate(present)}
155
165
  col_nodes = [[n for n in nodes if n["col"] == k] for k in present]
@@ -181,13 +191,13 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
181
191
  item_row = {it["id"]: (n["id"], 3 + j) for n in nodes for j, it in enumerate(n.get("items") or [])}
182
192
 
183
193
  # widths: as wide as the longest row wants, narrowed together when the whole is wider than the pane
184
- want = [min(32, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
185
- + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
186
- + [len(heading[present[i]]) + 1, 14]))
194
+ want = [min(most_w, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
195
+ + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
196
+ + [min(len(heading[present[i]]) + 1, most_w), least]))
187
197
  for i, col in enumerate(col_nodes)]
188
198
  label_w = max([len(e.get("label") or "") for e in edges] + [0])
189
- gaps = (8 + label_w) * len(present)
190
- while sum(want) + gaps > width and max(want) > 20:
199
+ gaps = (chan_min + 2 + label_w) * len(present)
200
+ while sum(want) + gaps > width and max(want) > least:
191
201
  biggest = max(range(len(want)), key=lambda i: want[i])
192
202
  want[biggest] -= 1
193
203
  heights = {n["id"]: 2 + len(contents[n["id"]]) for n in nodes}
@@ -320,7 +330,7 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
320
330
  if e.get("label") and cs != ct:
321
331
  label_in[hops[-1][0]] = max(label_in[hops[-1][0]], len(e["label"]) + 2)
322
332
  widths = want
323
- chan = [max(6, len(lanes[k]) + 4 + label_in[k]) for k in range(len(present))]
333
+ chan = [max(chan_min, len(lanes[k]) + chan_pad + label_in[k]) for k in range(len(present))]
324
334
  if not lanes[len(present) - 1]:
325
335
  chan[-1] = 0
326
336
  xs, x = [], 0
@@ -195,6 +195,7 @@ def parser() -> argparse.ArgumentParser:
195
195
  pn.add_argument("--width", type=int, default=80)
196
196
  pn.add_argument("--view", default="now", choices=["now", "map", "changes", "topics", "agents"])
197
197
  pn.add_argument("--part", default="", help="the part to zoom in on (default: the one the agent works on)")
198
+ pn.add_argument("--zoom", type=int, default=-1, help="diagrams 0 full, 1 compact, 2 tiny; -1 by the width")
198
199
  pn.add_argument("--protocol", type=int, default=1, help="2: a page of runs and pickable blocks (the mod's painter)")
199
200
  pf = sub.add_parser("profile", help="what you want explained: technologies marked new to you")
200
201
  pf.add_argument("--learn", metavar="TECH", help="mark a technology new to you, or unmark it")
@@ -254,7 +255,7 @@ def main(argv: Optional[List[str]] = None) -> int:
254
255
  return agent.run_hook(sys.stdin.read() if not sys.stdin.isatty() else "")
255
256
  if args.cmd == "panel":
256
257
  from . import panel
257
- return panel.main(root, args.width, args.view, args.part, args.protocol)
258
+ return panel.main(root, args.width, args.view, args.part, args.protocol, args.zoom)
258
259
  if args.cmd == "profile":
259
260
  from . import profile
260
261
  if args.learn:
@@ -35,7 +35,7 @@ def _name(nid: str, *bps: dict) -> str:
35
35
  def _diagram(page: canvas.Page, c, model: Dict[str, Any], width: int, centre: bool = False) -> canvas.Grid:
36
36
  """The diagram on the page, every box a block: Enter zooms in on it; its detail says what it uses and what uses
37
37
  it, all of it (the diagram draws each box's main flows)."""
38
- g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre)
38
+ g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre, zoom=ZOOM)
39
39
  conns = graph.connections(c.after)
40
40
  names = {n["id"]: n["title"] for n in model["nodes"]}
41
41
  names.update({i["id"]: i["title"] for n in model["nodes"] for i in n.get("items") or []})
@@ -476,7 +476,19 @@ def view_agents(root: Path, width: int) -> Dict[str, Any]:
476
476
  return page.json(view="agents", part="")
477
477
 
478
478
 
479
- def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
479
+ ZOOM = 0 # how compact the diagrams are drawn: 0 full, 1 compact, 2 tiny (show sets it)
480
+
481
+
482
+ def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None, zoom: int = -1) -> Dict[str, Any]:
483
+ """The page for a view. `zoom` -1: compact below 110 columns, full above."""
484
+ global ZOOM
485
+ ZOOM = (1 if width < 110 else 0) if zoom < 0 else max(0, min(2, zoom))
486
+ out = _show(root, width, view, part)
487
+ out["zoom"] = ZOOM
488
+ return out
489
+
490
+
491
+ def _show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
480
492
  if view == "agents":
481
493
  return view_agents(root, width)
482
494
  if view == "map":
@@ -232,10 +232,11 @@ def rows(root: Path, width: int = 80) -> List[textmap.Line]:
232
232
  return got["head"] + got["rows"]
233
233
 
234
234
 
235
- def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1) -> int:
235
+ def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1,
236
+ zoom: int = -1) -> int:
236
237
  if protocol >= 2:
237
238
  from . import pane
238
- print(json.dumps(pane.show(root, width, view, part or None), ensure_ascii=False))
239
+ print(json.dumps(pane.show(root, width, view, part or None, zoom), ensure_ascii=False))
239
240
  return 0
240
241
  got = show(root, width, view, part or None)
241
242
  for key in ("head", "rows"):
@@ -18,7 +18,7 @@ REFUND = ('from fastapi import APIRouter\nrouter = APIRouter()\n\n\n@router.post
18
18
 
19
19
 
20
20
  def page(capsys, repo, *args):
21
- assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", *args]) == 0
21
+ assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", "--zoom", "0", *args]) == 0
22
22
  out = json.loads(capsys.readouterr().out)
23
23
  return out, ["".join(run[0] for run in row) for row in out["rows"]]
24
24
 
@@ -268,3 +268,84 @@ def test_the_agents_tab_shows_each_agent_and_warns_when_two_edit_one_block(repo,
268
268
  assert any(t.startswith("THE TEAM plan: make the API") for t in text)
269
269
  assert any("api and store all edit API" in t for t in text)
270
270
  assert "● API @api @st" in "\n".join(text) # who works where, on the diagram
271
+
272
+
273
+ def test_messages_reach_an_agent_with_its_next_tool_result_of_any_kind_once(repo):
274
+ from system_diff import agent, agents
275
+ from system_diff.sources import git_dir
276
+
277
+ def tool(name, **given):
278
+ said: list = []
279
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "PostToolUse", "tool_name": name,
280
+ "agent_id": "a1", "tool_input": given}), emit=said.append)
281
+ return [json.loads(s) for s in said]
282
+
283
+ bus = git_dir(repo) / agents.BUS
284
+ assert not bus.exists() and tool("Bash", command="ls") == [] # no bus: nothing said, nothing written
285
+ assert not bus.exists()
286
+ agents.tie(repo, "a1", "api")
287
+ agents.publish(repo, "agent:api", "please keep GET /api/orders", sender="web")
288
+ out = tool("Bash", command="ls") # not a file tool: it still gets it
289
+ assert "web on agent:api: please keep GET /api/orders" in out[0]["hookSpecificOutput"]["additionalContext"]
290
+ assert out[0]["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
291
+ assert tool("Bash", command="ls") == [] # once only
292
+ agents.publish(repo, "agent:api", "store moved to app/db", sender="store")
293
+ out = tool("Read", file_path=str(repo / "app/core/rules.py"))
294
+ assert "store on agent:api: store moved to app/db" in out[0]["hookSpecificOutput"]["additionalContext"]
295
+ assert tool("Read", file_path=str(repo / "app/core/rules.py")) == []
296
+
297
+
298
+ def test_one_agent_editing_two_areas_in_a_turn_is_told_once_to_plan_a_team(repo, files):
299
+ from system_diff import agent
300
+
301
+ def prompt():
302
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "UserPromptSubmit", "prompt": "go"}),
303
+ emit=lambda _s: None)
304
+
305
+ def edit(path, text, **who):
306
+ files(repo, {path: text})
307
+ said: list = []
308
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "PostToolUse", "tool_name": "Edit", **who,
309
+ "tool_input": {"file_path": str(repo / path)}}), emit=said.append)
310
+ outs = [json.loads(s) for s in said]
311
+ warns = " ".join(o.get("systemMessage", "") for o in outs)
312
+ context = " ".join(o.get("hookSpecificOutput", {}).get("additionalContext", "") for o in outs)
313
+ return warns, context
314
+
315
+ routes, rules = "app/api/routes.py", "app/core/rules.py"
316
+ prompt()
317
+ warns, context = edit(routes, (repo / routes).read_text() + "\n# one\n")
318
+ assert "areas this turn" not in warns + context # one area so far
319
+ warns, context = edit(rules, (repo / rules).read_text() + "\n# one\n")
320
+ assert "main has edited blocks in 2 areas this turn" in warns and "plan_agents" in warns
321
+ assert "main has edited blocks in 2 areas this turn" in context and "plan_agents" in context
322
+ warns, context = edit("app/db/repo.py", (repo / "app/db/repo.py").read_text() + "\n# one\n")
323
+ assert "areas this turn" not in warns + context # once a turn
324
+ prompt()
325
+ edit(routes, (repo / routes).read_text() + "# two\n")
326
+ warns, context = edit(rules, (repo / rules).read_text() + "# two\n")
327
+ assert "areas this turn" in warns and "areas this turn" in context # a new turn may warn again
328
+ prompt()
329
+ for text in ("# a\n", "# b\n"): # a subagent staying in its one area
330
+ warns, context = edit(routes, (repo / routes).read_text() + text, agent_id="a1")
331
+ assert "areas this turn" not in warns and "a1 has edited" not in context # main's warning may reach it by bus
332
+
333
+
334
+ def test_a_narrow_pane_gets_compact_diagrams_unless_asked_otherwise(repo, capsys):
335
+ narrow = json.loads(_run(capsys, repo, "--width", "90"))
336
+ wide = json.loads(_run(capsys, repo, "--width", "140"))
337
+ assert narrow["zoom"] == 1 and wide["zoom"] == 0
338
+ assert json.loads(_run(capsys, repo, "--width", "90", "--zoom", "2"))["zoom"] == 2
339
+
340
+
341
+ def _run(capsys, repo, *args):
342
+ assert main(["--repo", str(repo), "panel", "--protocol", "2", *args]) == 0
343
+ return capsys.readouterr().out
344
+
345
+
346
+ def test_what_an_agent_read_before_it_had_a_name_stays_read(repo):
347
+ from system_diff import agents
348
+ agents.publish(repo, "all", "hello", sender="main")
349
+ assert len(agents.inbox(repo, "agent-a1b2c3")) == 1
350
+ agents.tie(repo, "a1b2c3d4", "api")
351
+ assert agents.inbox(repo, "api") == []
@@ -332,10 +332,8 @@ def test_setup_wires_three_hooks_and_a_status_line_and_leaves_someone_elses_alon
332
332
  agent.setup_claude(repo, "local", out=said.append)
333
333
  path = repo / ".claude" / "settings.local.json"
334
334
  data = json.loads(path.read_text())
335
- assert data["hooks"]["PostToolUse"] == [ # reads noted in the background; an edit waited for, so it can speak
336
- {"matcher": "Read", "hooks": [{"type": "command", "command": "system-diff hook", "async": True}]},
337
- {"matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [
338
- {"type": "command", "command": "system-diff hook", "timeout": 15}]}]
335
+ assert data["hooks"]["PostToolUse"] == [ # every tool, waited for: it can speak and hand over messages
336
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}]
339
337
  assert data["hooks"]["UserPromptSubmit"][0]["hooks"][0]["timeout"] == 10 # waited for: it tells Claude
340
338
  assert data["statusLine"] == {"type": "command", "command": "system-diff statusline"}
341
339
  agent.setup_claude(repo, "local", undo=True, out=said.append)
@@ -347,11 +345,25 @@ def test_setup_wires_three_hooks_and_a_status_line_and_leaves_someone_elses_alon
347
345
  said.clear()
348
346
  agent.setup_claude(repo, "local", out=said.append)
349
347
  post = json.loads(path.read_text())["hooks"]["PostToolUse"]
350
- assert post[0] == {"hooks": [{"command": "mine"}]} and [e["matcher"] for e in post[1:]] == [
351
- "Read", "Edit|Write|MultiEdit|NotebookEdit"]
348
+ assert post == [{"hooks": [{"command": "mine"}]},
349
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}]
352
350
  assert any(s.startswith("updated the PostToolUse hook") for s in said)
353
351
  agent.setup_claude(repo, "local", undo=True, out=said.append)
354
352
 
353
+ two = {"hooks": {"PostToolUse": [ # the two entries the setup before this one wrote
354
+ {"matcher": "Read", "hooks": [{"type": "command", "command": "system-diff hook", "async": True}]},
355
+ {"matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [
356
+ {"type": "command", "command": "system-diff hook", "timeout": 15}]}]}}
357
+ path.write_text(json.dumps(two))
358
+ said.clear()
359
+ agent.setup_claude(repo, "local", out=said.append)
360
+ assert json.loads(path.read_text())["hooks"]["PostToolUse"] == [
361
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}] # one, not three
362
+ assert sum(s.startswith("updated the PostToolUse hook") for s in said) == 1
363
+ agent.setup_claude(repo, "local", out=said.append)
364
+ assert len(json.loads(path.read_text())["hooks"]["PostToolUse"]) == 1 # and it stays one
365
+ agent.setup_claude(repo, "local", undo=True, out=said.append)
366
+
355
367
  user = home / ".claude" / "settings.json"
356
368
  user.parent.mkdir(parents=True)
357
369
  user.write_text(json.dumps({"statusLine": {"type": "command", "command": "my-line"}}))
File without changes
File without changes
File without changes