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.
- agent_timelines/__init__.py +0 -0
- agent_timelines/__main__.py +3 -0
- agent_timelines/actors.js +202 -0
- agent_timelines/assets/LICENSES.txt +98 -0
- agent_timelines/assets/PressStart2P-Regular.ttf +0 -0
- agent_timelines/assets/one-bit.png +0 -0
- agent_timelines/assets/tiny-dungeon.png +0 -0
- agent_timelines/cli.py +197 -0
- agent_timelines/dungeon.js +211 -0
- agent_timelines/gantt.js +63 -0
- agent_timelines/lobby.js +46 -0
- agent_timelines/timeline.py +579 -0
- agent_timelines/viewer.html +474 -0
- agent_timelines-0.2.0.dist-info/METADATA +153 -0
- agent_timelines-0.2.0.dist-info/RECORD +20 -0
- agent_timelines-0.2.0.dist-info/WHEEL +5 -0
- agent_timelines-0.2.0.dist-info/entry_points.txt +2 -0
- agent_timelines-0.2.0.dist-info/licenses/LICENSE +24 -0
- agent_timelines-0.2.0.dist-info/licenses/src/agent_timelines/assets/LICENSES.txt +98 -0
- agent_timelines-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -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
|
|
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)
|