agent-timelines 0.2.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,202 @@
1
+ /* Characters for the dungeon: the guild master in the hall, one hero + enemy per agent room, a chest per workflow.
2
+ * Sprites follow the timeline viewer exactly (same HERO map and rules; tests/test_actors.js compares them).
3
+ * place() is pure (seed + dungeon + nodes -> actors); stamp() remembers when each state began; draw() runs per frame.
4
+ * Actors not working (idle guild master, a hero after its victory) wander their room's free cells: never onto props,
5
+ * doors, corridors or the fallen enemy. walk() holds that motion and is canvas-free so it can be tested.
6
+ * Node fields are the viewer's: {id, tier, kind, running, flags, wf:{state}}. Plain ES5; window.Actors / module.exports. */
7
+ (function (root) {
8
+ 'use strict';
9
+ var D = root.Dungeon || (typeof require !== 'undefined' && require('./dungeon.js'));
10
+ // [sheet, col, row]: keep identical to HERO in viewer.html
11
+ var HERO = {main:["td",0,7],executor:["td",0,8],scanner:["td",1,7],plan:["td",4,8],writer:["td",3,8],runner:["td",4,7],other:["td",3,7],workflow:["td",5,7],wfrun:["td",8,7],ghost:["td",1,10]};
12
+ var FOES = [108, 109, 110, 111, 120, 122, 123, 124]; // td slime, cyclops, crab, hooded cultist, bat, spider, rat, grey rat
13
+ var OPEN_CHEST = 91; // td open chest: a finished workflow
14
+ var OB_BONES = 721; // ob bone pile under a fallen enemy
15
+ var RUG = { 629: 1, 630: 1, 631: 1 }; // ob carpet pieces: the only props that may be walked on
16
+ var STEP_MS = 220; // one cell of running
17
+ var OB_TROPHY = 808; // ob trophy raised on victory (tinted gold when drawn)
18
+
19
+ function cls(n) { return n.id === 'main' ? 'main' : n.kind === 'wf' ? 'workflow' : HERO[n.tier] ? n.tier : 'other'; }
20
+ function sprite(n) { // the viewer's sprite(): a berserk hero (ignored the tool limit) is a ghost, a running workflow a mimic
21
+ var berserk = n.id !== 'main' && n.kind !== 'wf' && !n.running && (n.flags || []).indexOf('ignored-limit') >= 0;
22
+ var s = berserk ? HERO.ghost : n.kind === 'wf' && n.running ? HERO.wfrun : HERO[cls(n)];
23
+ return s[2] * 12 + s[1];
24
+ }
25
+
26
+ function place(seed, d, nodes) {
27
+ var solid = {}, path = {}, rooms = {}, out = [];
28
+ d.deco.forEach(function (e) { if (!(e.sheet === 'ob' && RUG[e.idx])) solid[e.x + ',' + e.y] = 1; });
29
+ d.paths.forEach(function (p) { p.cells.forEach(function (c) { path[c[0] + ',' + c[1]] = 1; }); });
30
+ d.rooms.forEach(function (q) { rooms[q.id] = q; });
31
+ function free(q, x, y) { return x >= q.x && x < q.x + q.w && y >= q.y && y < q.y + q.h && !solid[x + ',' + y] && !path[x + ',' + y]; }
32
+ function near(q, x, y, two) { // closest free cell to (x, y) in rings outward; `two` also needs its right neighbour free
33
+ for (var r = 0; r < q.w + q.h; r++) for (var dy = -r; dy <= r; dy++) for (var dx = -r; dx <= r; dx++) {
34
+ if (Math.max(Math.abs(dx), Math.abs(dy)) === r && free(q, x + dx, y + dy) && (!two || free(q, x + dx + 1, y + dy))) return [x + dx, y + dy];
35
+ }
36
+ return [q.cx, q.cy];
37
+ }
38
+ function room(q, skip) { // free cells of q, minus `skip`, as {"x,y": 1}
39
+ var f = {}, x, y;
40
+ for (y = q.y; y < q.y + q.h; y++) for (x = q.x; x < q.x + q.w; x++) if (free(q, x, y) && x + ',' + y !== skip) f[x + ',' + y] = 1;
41
+ return f;
42
+ }
43
+ nodes.forEach(function (n) {
44
+ var q = rooms[n.id], a;
45
+ if (!q) return;
46
+ if (q.hall) { a = near(q, q.x + 2, q.y); out.push({ id: n.id, kind: 'gm', idx: sprite(n), x: a[0], y: a[1], state: n.running ? 'cast' : 'idle', free: room(q) }); return; }
47
+ if (n.kind === 'wf') {
48
+ a = near(q, q.cx, q.cy);
49
+ out.push({ id: n.id, kind: 'wf', idx: n.running ? sprite(n) : n.wf && n.wf.state === 'failed' ? sprite(n) : OPEN_CHEST, x: a[0], y: a[1],
50
+ state: n.running ? 'fight' : 'win', failed: !n.running && !!n.wf && n.wf.state === 'failed' });
51
+ return;
52
+ }
53
+ a = near(q, q.cx - 1, q.cy, true);
54
+ out.push({ id: n.id, kind: 'hero', idx: sprite(n), x: a[0], y: a[1], state: n.running ? 'fight' : 'win',
55
+ foe: FOES[D.prng(seed + '/foe/' + n.id)() * FOES.length | 0], fx: a[0] + 1, fy: a[1], free: room(q, (a[0] + 1) + ',' + a[1]) });
56
+ });
57
+ return out;
58
+ }
59
+
60
+ // mem: {id: {state, since}} kept by the caller. An actor first seen gets since = -Infinity, so a page opened on a
61
+ // finished session shows the resting pose instead of replaying every victory.
62
+ function stamp(actors, mem, now) {
63
+ actors.forEach(function (a) {
64
+ var m = mem[a.id];
65
+ // A hero's new state starts a fresh walk from its home cell; the guild master keeps walking through idle <-> cast.
66
+ if (!m || m.state !== a.state) mem[a.id] = { state: a.state, since: m ? now : -Infinity,
67
+ walk: m && a.kind === 'gm' ? m.walk : { r: D.prng('walk/' + a.id + '/' + now) } };
68
+ a.since = mem[a.id].since; a.walk = mem[a.id].walk;
69
+ });
70
+ }
71
+
72
+ function route(free, from, to) { // BFS over free cells; the cells after `from`, ending at `to` ([] if unreachable)
73
+ var prev = {}, q = [from], k = from + '', i, c;
74
+ prev[k] = null;
75
+ while (q.length) {
76
+ c = q.shift();
77
+ if (c[0] === to[0] && c[1] === to[1]) { var out = []; for (; prev[c + ''] !== null; c = prev[c + '']) out.unshift(c); return out; }
78
+ for (i = 0; i < 4; i++) {
79
+ var n = [c[0] + [1, -1, 0, 0][i], c[1] + [0, 0, 1, -1][i]];
80
+ if (free[n[0] + ',' + n[1]] && !(n + '' in prev)) { prev[n + ''] = c; q.push(n); }
81
+ }
82
+ }
83
+ return [];
84
+ }
85
+
86
+ // Position of a resting actor at `now`, in fractional cells: run to a random free cell, pause 1-3 s, repeat.
87
+ // The loop replays whole steps, so a late frame (hidden tab) catches up instead of skipping cells.
88
+ function walk(a, now) {
89
+ var w = a.walk, cells = Object.keys(a.free);
90
+ if (!w.at) { w.at = [a.x, a.y]; w.path = []; w.until = now + 400 + w.r() * 1500; w.face = 1; }
91
+ for (;;) {
92
+ if (!w.path.length) {
93
+ if (now < w.until || !cells.length) return { x: w.at[0], y: w.at[1], moving: false, face: w.face };
94
+ var t = cells[w.r() * cells.length | 0].split(',');
95
+ w.path = route(a.free, w.at, [+t[0], +t[1]]); w.t0 = w.until;
96
+ if (!w.path.length) { w.until += 1000; continue; }
97
+ }
98
+ var n = w.path[0], f = (now - w.t0) / STEP_MS;
99
+ if (n[0] !== w.at[0]) w.face = n[0] > w.at[0] ? 1 : -1;
100
+ if (f < 1) return { x: w.at[0] + (n[0] - w.at[0]) * f, y: w.at[1] + (n[1] - w.at[1]) * f, moving: true, face: w.face };
101
+ w.at = w.path.shift(); w.t0 += STEP_MS;
102
+ if (!w.path.length) w.until = w.t0 + 1000 + w.r() * 2000;
103
+ }
104
+ }
105
+
106
+ var tints = {};
107
+ function tinted(img, cols, idx, color, mix) { // cached recolour of one sprite: mix 1 = solid silhouette (hit flash), <1 keeps shading
108
+ var k = cols + '/' + idx + color + mix;
109
+ if (tints[k]) return tints[k];
110
+ var c = document.createElement('canvas'), x = c.getContext('2d');
111
+ c.width = c.height = 16;
112
+ x.drawImage(img, idx % cols * 16, (idx / cols | 0) * 16, 16, 16, 0, 0, 16, 16);
113
+ x.globalCompositeOperation = 'source-atop'; x.globalAlpha = mix; x.fillStyle = color; x.fillRect(0, 0, 16, 16);
114
+ return (tints[k] = c);
115
+ }
116
+
117
+ // Every offset is a whole number of sprite pixels and every animation steps (no tweening), so it stays pixel art.
118
+ // still = true (reduced motion) draws each state's resting pose.
119
+ function draw(ctx, d, actors, sheets, scale, now, still) {
120
+ var S = 16 * scale, p = scale, td = sheets.td, i;
121
+ ctx.imageSmoothingEnabled = false;
122
+ function spr(idx, x, y, o) {
123
+ o = o || {};
124
+ ctx.save(); ctx.globalAlpha = o.alpha == null ? 1 : o.alpha;
125
+ ctx.translate(x + S / 2, y + S / 2); if (o.flip) ctx.scale(-1, 1); if (o.rot) ctx.rotate(o.rot);
126
+ if (o.flash) ctx.drawImage(tinted(td, 12, idx, '#fff', 1), -S / 2, -S / 2, S, S);
127
+ else ctx.drawImage(td, idx % 12 * 16, (idx / 12 | 0) * 16, 16, 16, -S / 2, -S / 2, S, S);
128
+ ctx.restore();
129
+ }
130
+ function ob(idx, x, y, alpha, gold) {
131
+ ctx.save(); ctx.globalAlpha = alpha;
132
+ if (gold) ctx.drawImage(tinted(sheets.ob, 48, idx, '#ffc21a', 0.75), x, y, S, S);
133
+ else ctx.drawImage(sheets.ob, idx % 48 * 16, (idx / 48 | 0) * 16, 16, 16, x, y, S, S);
134
+ ctx.restore();
135
+ }
136
+ function dot(color, x, y, n) { ctx.fillStyle = color; ctx.fillRect(Math.round(x / p) * p, Math.round(y / p) * p, (n || 1) * p, (n || 1) * p); }
137
+ function step(period, n, off) { return still ? 0 : Math.floor((now + off) % period / period * n); }
138
+
139
+ actors.forEach(function (a) {
140
+ var X = (a.x - d.ox) * S, Y = (a.y - d.oy) * S, off = D.prng('t/' + a.id)() * 5000 | 0, k;
141
+ if (a.kind === 'gm') return rest(a, a.idx); // always roams the hall; casts at each stop while work is going on
142
+ if (a.kind === 'wf') return spr(a.idx, X + (a.state === 'fight' ? (step(300, 2, off) ? p : -p) : 0), Y, { alpha: a.failed ? 0.5 : 1 });
143
+
144
+ var FX = (a.fx - d.ox) * S, el = now - a.since;
145
+ if (a.state === 'fight') { // 6 beats: wind-up, lunge, hit (enemy flashes, slash, sparks), recoil, counter-attack, reset
146
+ k = step(900, 6, off);
147
+ var hx = [0, 2, 4, 1, 0, 0][k] * p, ex = [0, 0, 2, 1, -3, 0][k] * p;
148
+ spr(a.idx, X + hx, Y);
149
+ if (k === 4) spr(a.idx, X + hx, Y, { flash: true, alpha: 0.6 });
150
+ spr(a.foe, FX + ex, Y, { flip: true, flash: k === 2 });
151
+ if (k === 2) {
152
+ for (i = 0; i < 4; i++) dot('#ffffff', X + S + i * p, Y + (2 + i * 3) * p, 2);
153
+ dot('#ffd23f', FX + 2 * p, Y + 2 * p); dot('#ffd23f', FX + 12 * p, Y + 5 * p); dot('#ff5a5a', FX + 7 * p, Y);
154
+ }
155
+ return;
156
+ }
157
+ // Finished: for 1.6 s after the change the enemy blinks and falls, the hero hops twice under a raised trophy
158
+ // with a burst of gold; after that the hero rests and cheers (a hop and a sparkle) every few seconds.
159
+ var v = still ? 1 : Math.min(1, el / 1600), fell = v > 0.3;
160
+ if (fell) { ob(OB_BONES, FX, Y + 2 * p, 0.9); spr(a.foe, FX, Y + 3 * p, { flip: true, rot: Math.PI / 2, alpha: 0.55 }); }
161
+ else spr(a.foe, FX, Y, { flip: true, flash: step(200, 2, 0) === 1 });
162
+ if (v < 1) {
163
+ var hop = Math.round(Math.abs(Math.sin(v * 2 * Math.PI)) * 6) * p;
164
+ spr(a.idx, X, Y - hop);
165
+ ob(OB_TROPHY, X, Y - S - 3 * p - Math.round(v * 6) * p, v < 0.7 ? 1 : (1 - v) / 0.3, true); // above the hop's peak
166
+ for (i = 0; i < 8; i++) {
167
+ var g = i / 8 * 6.2832;
168
+ dot(i % 2 ? '#ffd23f' : '#fff3b0', X + S / 2 + Math.cos(g) * v * 14 * p, Y + S / 2 - hop + Math.sin(g) * v * 14 * p);
169
+ }
170
+ return;
171
+ }
172
+ rest(a, a.idx);
173
+ });
174
+ // Not working: wander (hop while running, face the way it runs); while paused, bob and now and then cheer.
175
+ function rest(a, idx) {
176
+ var o = still ? { x: a.x, y: a.y, moving: false, face: 1 } : walk(a, now), off = D.prng('t/' + a.id)() * 5000 | 0;
177
+ var X = Math.round((o.x - d.ox) * 16) * p, Y = Math.round((o.y - d.oy) * 16) * p;
178
+ if (o.moving) return spr(idx, X, Y - step(220, 2, off) * p, { flip: o.face < 0 });
179
+ if (a.state === 'cast') return cast(idx, X, Y, o.face, off);
180
+ var k = step(4000, 10, off);
181
+ spr(idx, X, Y - (k === 0 && a.kind === 'hero' ? 3 : step(1600, 2, off)) * p, { flip: o.face < 0 });
182
+ if (k === 0 && a.kind === 'hero') { dot('#ffd23f', X + 2 * p, Y - 2 * p); dot('#fff3b0', X + 13 * p, Y - p); }
183
+ }
184
+ function cast(idx, X, Y, face, off) { // magic circle on the floor, sparks round the hand, a rune rising off the staff
185
+ var k = step(800, 8, off), j;
186
+ for (j = 0; j < 4; j++) {
187
+ var c = (j / 4 + k / 8) * 6.2832;
188
+ dot(j % 2 ? '#b48cff' : '#5ab4ff', X + S / 2 + Math.cos(c) * 9 * p, Y + S - p + Math.sin(c) * 3 * p, 2);
189
+ }
190
+ for (j = 0; j < 3; j++) {
191
+ var b = (j / 3 + k / 8) * 6.2832;
192
+ dot(['#ffffff', '#ffd23f', '#b48cff'][j], X + S / 2 + Math.cos(b) * 7 * p, Y + 4 * p + Math.sin(b) * 4 * p, 2);
193
+ }
194
+ dot('#b48cff', face < 0 ? X + p : X + 13 * p, Y - (k % 4) * 3 * p, 2);
195
+ spr(idx, X, Y - (k % 2) * p, { flip: face < 0 });
196
+ }
197
+ }
198
+
199
+ var Actors = { place: place, stamp: stamp, draw: draw, walk: walk, sprite: sprite, HERO: HERO, FOES: FOES, OPEN_CHEST: OPEN_CHEST };
200
+ if (typeof module !== 'undefined' && module.exports) module.exports = Actors;
201
+ root.Actors = Actors;
202
+ })(typeof window !== 'undefined' ? window : this);
@@ -0,0 +1,98 @@
1
+ tiny-dungeon.png Kenney "Tiny Dungeon" 1.0 (www.kenney.nl), CC0 1.0. Source: https://opengameart.org/content/tiny-dungeon
2
+ one-bit.png Kenney "1-Bit Pack" 1.1 (www.kenney.nl), CC0 1.0. Source: https://opengameart.org/content/1-bit-pack
3
+ PressStart2P-Regular.ttf Copyright 2012 The Press Start 2P Project Authors, SIL Open Font License 1.1 (text below).
4
+ Source: https://github.com/google/fonts/tree/main/ofl/pressstart2p
5
+
6
+ Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P".
7
+
8
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
9
+ This license is copied below, and is also available with a FAQ at:
10
+ http://scripts.sil.org/OFL
11
+
12
+
13
+ -----------------------------------------------------------
14
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
15
+ -----------------------------------------------------------
16
+
17
+ PREAMBLE
18
+ The goals of the Open Font License (OFL) are to stimulate worldwide
19
+ development of collaborative font projects, to support the font creation
20
+ efforts of academic and linguistic communities, and to provide a free and
21
+ open framework in which fonts may be shared and improved in partnership
22
+ with others.
23
+
24
+ The OFL allows the licensed fonts to be used, studied, modified and
25
+ redistributed freely as long as they are not sold by themselves. The
26
+ fonts, including any derivative works, can be bundled, embedded,
27
+ redistributed and/or sold with any software provided that any reserved
28
+ names are not used by derivative works. The fonts and derivatives,
29
+ however, cannot be released under any other type of license. The
30
+ requirement for fonts to remain under this license does not apply
31
+ to any document created using the fonts or their derivatives.
32
+
33
+ DEFINITIONS
34
+ "Font Software" refers to the set of files released by the Copyright
35
+ Holder(s) under this license and clearly marked as such. This may
36
+ include source files, build scripts and documentation.
37
+
38
+ "Reserved Font Name" refers to any names specified as such after the
39
+ copyright statement(s).
40
+
41
+ "Original Version" refers to the collection of Font Software components as
42
+ distributed by the Copyright Holder(s).
43
+
44
+ "Modified Version" refers to any derivative made by adding to, deleting,
45
+ or substituting -- in part or in whole -- any of the components of the
46
+ Original Version, by changing formats or by porting the Font Software to a
47
+ new environment.
48
+
49
+ "Author" refers to any designer, engineer, programmer, technical
50
+ writer or other person who contributed to the Font Software.
51
+
52
+ PERMISSION & CONDITIONS
53
+ Permission is hereby granted, free of charge, to any person obtaining
54
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
55
+ redistribute, and sell modified and unmodified copies of the Font
56
+ Software, subject to the following conditions:
57
+
58
+ 1) Neither the Font Software nor any of its individual components,
59
+ in Original or Modified Versions, may be sold by itself.
60
+
61
+ 2) Original or Modified Versions of the Font Software may be bundled,
62
+ redistributed and/or sold with any software, provided that each copy
63
+ contains the above copyright notice and this license. These can be
64
+ included either as stand-alone text files, human-readable headers or
65
+ in the appropriate machine-readable metadata fields within text or
66
+ binary files as long as those fields can be easily viewed by the user.
67
+
68
+ 3) No Modified Version of the Font Software may use the Reserved Font
69
+ Name(s) unless explicit written permission is granted by the corresponding
70
+ Copyright Holder. This restriction only applies to the primary font name as
71
+ presented to the users.
72
+
73
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
74
+ Software shall not be used to promote, endorse or advertise any
75
+ Modified Version, except to acknowledge the contribution(s) of the
76
+ Copyright Holder(s) and the Author(s) or with their explicit written
77
+ permission.
78
+
79
+ 5) The Font Software, modified or unmodified, in part or in whole,
80
+ must be distributed entirely under this license, and must not be
81
+ distributed under any other license. The requirement for fonts to
82
+ remain under this license does not apply to any document created
83
+ using the Font Software.
84
+
85
+ TERMINATION
86
+ This license becomes null and void if any of the above conditions are
87
+ not met.
88
+
89
+ DISCLAIMER
90
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
91
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
92
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
93
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
94
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
95
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
96
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
97
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
98
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
Binary file
agent_timelines/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ """agent-timelines: install the Claude Code hooks, build session data, serve the dashboard."""
2
+ import argparse, functools, glob, http.server, json, os, shlex, shutil, subprocess, sys, tempfile, time, urllib.parse, webbrowser
3
+ from datetime import datetime
4
+
5
+ from . import timeline as tl
6
+
7
+ EVENTS = {"UserPromptSubmit": "", "PostToolUse": "*", "SubagentStart": "", "SubagentStop": "", "Stop": "", "SessionEnd": ""} # event -> matcher
8
+ MARK = "agent_timelines hook" # every hook command this tool writes contains it
9
+ OLD = "agent-timeline.py" # hooks of the pre-package script, replaced on install
10
+ THROTTLE = 3 # s between PostToolUse rebuilds of one session
11
+
12
+
13
+ def detach(*args):
14
+ subprocess.Popen(list(args), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
15
+
16
+
17
+ def strip(hooks):
18
+ """Remove this tool's (and the old script's) hook commands; drop groups and events left empty."""
19
+ for ev in list(hooks):
20
+ for g in hooks[ev]:
21
+ g["hooks"] = [h for h in g.get("hooks", []) if not any(m in str(h.get("command", "")) for m in (MARK, OLD))]
22
+ hooks[ev] = [g for g in hooks[ev] if g["hooks"]]
23
+ if not hooks[ev]:
24
+ del hooks[ev]
25
+
26
+
27
+ def edit_settings(path, edit):
28
+ path = os.path.expanduser(path or os.path.join(tl.CLAUDE_DIR, "settings.json"))
29
+ try:
30
+ with open(path, encoding="utf-8") as f:
31
+ cfg = json.load(f)
32
+ bak = "%s.bak-agent-timelines-%s" % (path, time.strftime("%Y%m%d-%H%M%S"))
33
+ shutil.copy2(path, bak)
34
+ print("backup:", bak)
35
+ except FileNotFoundError:
36
+ cfg = {}
37
+ os.makedirs(os.path.dirname(path), exist_ok=True)
38
+ edit(cfg.setdefault("hooks", {}))
39
+ if not cfg["hooks"]:
40
+ del cfg["hooks"]
41
+ tl.write_atomic(path, json.dumps(cfg, indent=2, ensure_ascii=False) + "\n")
42
+ return path
43
+
44
+
45
+ def install(a):
46
+ cmd = "%s -m %s" % (shlex.quote(sys.executable), MARK) # absolute python: hooks do not get the login PATH
47
+
48
+ def edit(hooks):
49
+ strip(hooks)
50
+ for ev, matcher in EVENTS.items():
51
+ hooks.setdefault(ev, []).append(dict({"matcher": matcher} if matcher else {}, hooks=[{"type": "command", "command": cmd}]))
52
+ print("installed %d hooks in %s\nfill the lobby with past sessions: agent-timelines rebuild\nopen the dashboard: agent-timelines serve\n(using the Claude Code plugin instead? then skip this command)" % (len(EVENTS), edit_settings(a.settings, edit)))
53
+
54
+
55
+ def uninstall(a):
56
+ print("removed agent-timelines hooks from", edit_settings(a.settings, strip))
57
+
58
+
59
+ def plugin_duplicate():
60
+ """True when settings.json already holds this tool's hooks, so the plugin's copy stays quiet."""
61
+ try:
62
+ with open(os.path.join(tl.CLAUDE_DIR, "settings.json"), encoding="utf-8") as f:
63
+ return MARK in f.read()
64
+ except OSError:
65
+ return False
66
+
67
+
68
+ def hook(a):
69
+ """Claude Code passes the event JSON on stdin. Never fail the hook, never block it: the rebuild runs detached."""
70
+ try:
71
+ if a.via == "plugin" and plugin_duplicate():
72
+ return
73
+ d = json.load(sys.stdin)
74
+ sid, tp, ev = d.get("session_id"), d.get("transcript_path"), d.get("hook_event_name") or ""
75
+ data = os.path.join(tl.OUT_DIR, "%s.data.js" % sid)
76
+ if not sid or not tp or ev == "PostToolUse" and os.path.exists(data) and time.time() - os.path.getmtime(data) < THROTTLE:
77
+ return # shortcut: the last tool call of a burst may be skipped; Stop/SubagentStop catch up
78
+ detach(sys.executable, "-m", "agent_timelines", "build", tp, "--event", ev, "--closed", d.get("agent_id") or "")
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def build(a):
84
+ tp = a.transcript.split("/subagents/")[0] + ".jsonl" if "/subagents/" in a.transcript else a.transcript # subagent events may name their own file
85
+ sid = os.path.basename(tp)[:-6]
86
+ closed = tl.closed_ids(tl.OUT_DIR, sid, a.event, a.closed)
87
+ d = tl.load(tp, closed=closed, ended=a.event == "SessionEnd", event=a.event)
88
+ page = tl.publish(d, closed=closed)
89
+ # No hook fires when a Workflow ends, so while one looks live this re-runs itself every 20s.
90
+ # shortcut: gives up after 30 re-runs (10 min); upgrade if Claude Code adds a workflow-end hook event.
91
+ if a.event and a.recheck < 30 and any(n.get("kind") == "wf" and n["running"] for n in d["agents"]):
92
+ mark = os.path.join(tempfile.gettempdir(), "agent-timeline-%s.recheck" % sid)
93
+ if a.recheck or not os.path.exists(mark) or time.time() - os.path.getmtime(mark) >= 25: # else another chain is polling
94
+ open(mark, "w").close()
95
+ detach("sh", "-c", 'sleep 20; exec "$@"', "sh", sys.executable, "-m", "agent_timelines", "build", tp, "--event", "Recheck",
96
+ "--recheck", str(a.recheck + 1))
97
+ return page
98
+
99
+
100
+ def open_(a):
101
+ files = tl.find(a.session)
102
+ if not files:
103
+ sys.exit("no session found")
104
+ page = build(argparse.Namespace(transcript=files[0], event=None, closed=None, recheck=0))
105
+ print(page)
106
+ webbrowser.open("file://" + page)
107
+
108
+
109
+ def rebuild(a):
110
+ """Regenerate data for every transcript touched in the last `days` (fills the lobby on a first install)."""
111
+ files = [f for f in tl.find() if time.time() - os.path.getmtime(f) < a.days * 86400]
112
+ for i, f in enumerate(files, 1):
113
+ try:
114
+ build(argparse.Namespace(transcript=f, event=None, closed=None, recheck=0))
115
+ except Exception as e: # one unreadable transcript must not stop the rest
116
+ print("skipped %s: %s" % (os.path.basename(f), e))
117
+ if sys.stdout.isatty(): # a live counter in a terminal; captured output gets only the summary
118
+ print("\r%d/%d" % (i, len(files)), end="", flush=True)
119
+ print("\r" if sys.stdout.isatty() else "", end="")
120
+ print("rebuilt %d sessions into %s" % (len(files), tl.OUT_DIR))
121
+
122
+
123
+ def list_(a):
124
+ for p in tl.find()[:20]:
125
+ n = len(glob.glob(os.path.join(p[:-6], "subagents", "*.meta.json")))
126
+ print(os.path.basename(p)[:-6], datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M"), n, "subagents")
127
+
128
+
129
+ class Quiet(http.server.SimpleHTTPRequestHandler):
130
+ def log_message(self, *args):
131
+ pass
132
+
133
+ def send_head(self):
134
+ """Serve only same-origin requests: the files hold prompt text and are scripts any other site could include."""
135
+ port = self.server.server_address[1]
136
+ ours = ("127.0.0.1:%d" % port, "localhost:%d" % port)
137
+ ref = self.headers.get("Referer") or ""
138
+ # a top-level page load (typed URL, link from another site, tool-driven browser) exposes nothing: only scripts and fetches could read the data
139
+ nav = self.headers.get("Sec-Fetch-Mode") == "navigate" and self.headers.get("Sec-Fetch-Dest") == "document"
140
+ if self.headers.get("Host") not in ours or not nav and (self.headers.get("Sec-Fetch-Site") not in (None, "same-origin", "none")
141
+ or ref and urllib.parse.urlsplit(ref).netloc not in ours):
142
+ self.send_error(403, "cross-site request refused")
143
+ return None
144
+ return super().send_head()
145
+
146
+ def end_headers(self):
147
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
148
+ super().end_headers()
149
+
150
+
151
+ def make_server(port, directory=None):
152
+ # 127.0.0.1 only: the pages hold prompt text from your sessions
153
+ return http.server.ThreadingHTTPServer(("127.0.0.1", port), functools.partial(Quiet, directory=directory or tl.OUT_DIR))
154
+
155
+
156
+ def serve(a):
157
+ tl.build_index()
158
+ srv = make_server(a.port)
159
+ url = "http://127.0.0.1:%d/index.html" % srv.server_address[1]
160
+ print("serving", tl.OUT_DIR, "at", url, "(ctrl-c to stop)")
161
+ if not a.no_browser:
162
+ webbrowser.open(url)
163
+ try:
164
+ srv.serve_forever()
165
+ except KeyboardInterrupt:
166
+ pass
167
+
168
+
169
+ def main(argv=None):
170
+ p = argparse.ArgumentParser(prog="agent-timelines", description="Live delegation graph of Claude Code sessions and subagents.")
171
+ sub = p.add_subparsers(dest="cmd", required=True)
172
+ for name, fn, help_ in (("install", install, "add the hooks to Claude Code settings"), ("uninstall", uninstall, "remove the hooks")):
173
+ sp = sub.add_parser(name, help=help_)
174
+ sp.add_argument("--settings", help="settings.json to edit (default: $CLAUDE_CONFIG_DIR or ~/.claude)")
175
+ sp.set_defaults(fn=fn)
176
+ sp = sub.add_parser("serve", help="serve the dashboard on localhost and open it")
177
+ sp.add_argument("--port", type=int, default=8765)
178
+ sp.add_argument("--no-browser", action="store_true")
179
+ sp.set_defaults(fn=serve)
180
+ sp = sub.add_parser("open", help="rebuild one session (newest, or by id prefix) and open it")
181
+ sp.add_argument("session", nargs="?")
182
+ sp.set_defaults(fn=open_)
183
+ sub.add_parser("list", help="list recent sessions").set_defaults(fn=list_)
184
+ sp = sub.add_parser("rebuild", help="regenerate data for recent sessions from their transcripts")
185
+ sp.add_argument("--days", type=float, default=tl.RETAIN_DAYS)
186
+ sp.set_defaults(fn=rebuild)
187
+ sp = sub.add_parser("hook", help="(called by Claude Code) handle one hook event from stdin")
188
+ sp.add_argument("--via", help="'plugin' when the Claude Code plugin calls it")
189
+ sp.set_defaults(fn=hook)
190
+ sp = sub.add_parser("build", help="(called by the hook) rebuild one session's data")
191
+ sp.add_argument("transcript")
192
+ sp.add_argument("--event")
193
+ sp.add_argument("--closed")
194
+ sp.add_argument("--recheck", type=int, default=0)
195
+ sp.set_defaults(fn=build)
196
+ a = p.parse_args(argv)
197
+ a.fn(a)