attesso 0.7.1 → 0.9.0

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.
package/README.md CHANGED
@@ -42,11 +42,28 @@ export ATTESSO_SYNC_URL=https://api.attesso.com
42
42
  export ATTESSO_API_KEY=att_live_...
43
43
  npx attesso sync --dry-run
44
44
  npx attesso sync
45
+ npx attesso sync --watch
45
46
  ```
46
47
 
48
+ ## Hosted policy
49
+
50
+ A hosted workspace can publish its portable rule set from the dashboard. The
51
+ local engine fetches the signed bundle, verifies it against Attesso's published
52
+ JWKS and only then writes `.attesso/rules.json`; path scope stays local.
53
+
54
+ ```sh
55
+ export ATTESSO_SYNC_URL=https://api.attesso.com
56
+ export ATTESSO_API_KEY=att_live_...
57
+ npx attesso policy pull --apply
58
+ ```
59
+
60
+ Without `--apply` the command verifies and previews the version and digest. A
61
+ tampered signature, an unknown key or an invalid rule set writes nothing.
62
+
47
63
  The API key must belong to an entitled live workspace. A retry after a failed
48
64
  request is safe: the server deduplicates by device and sequence, and the local
49
- cursor does not move until the batch is accepted.
65
+ cursor does not move until the batch is accepted. `--watch` repeats every five
66
+ seconds, so the hosted view follows a running agent without another command.
50
67
 
51
68
  ## The overview
52
69
 
package/bin/attesso.mjs CHANGED
@@ -29,7 +29,8 @@ import { resolve } from 'node:path';
29
29
  import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
30
30
  import { validatePathsConfig } from './../src/fs-paths.mjs';
31
31
  import { startUi } from './../src/ui/server.mjs';
32
- import { sync } from './../src/sync.mjs';
32
+ import { sync, watchSync } from './../src/sync.mjs';
33
+ import { pullPolicy } from './../src/remote-policy.mjs';
33
34
 
34
35
  const argv = process.argv.slice(2);
35
36
  const dirAt = argv.indexOf('--dir');
@@ -71,14 +72,18 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
71
72
  attesso hook [--observe] harness hook: one tool call on stdin, native refusal on stdout
72
73
  attesso setup [--write] [--enforce] [--dry-run]
73
74
  detect a harness, wire the hook, write the agent block
74
- attesso connect [--write] [--mode observe|enforce|off] [--json]
75
+ attesso connect [--write] [--mode observe|enforce|off] [--harness KEY] [--json]
75
76
  self-service: init, mode, hook and AGENTS.md
76
77
  attesso status [--json] connection, mode and wired harnesses
77
78
  attesso mode [observe|enforce|off] read or set the active hook mode
78
79
  attesso paths [--json|--set FILE] read or replace the filesystem scope
79
80
  attesso ui [--port N] [--no-open] local control surface for the signed ledger
80
- attesso sync [--url URL] [--limit N] [--json] [--dry-run]
81
+ attesso policy pull [--url URL] [--apply] [--json]
82
+ verify the hosted rule bundle and optionally
83
+ write rules.json (key from ATTESSO_API_KEY)
84
+ attesso sync [--url URL] [--limit N] [--json] [--dry-run] [--watch]
81
85
  send the next decisions to the hosted mirror
86
+ --watch keeps it current every 5 seconds
82
87
  (key from ATTESSO_API_KEY; never printed)
83
88
  attesso explain <seq> one entry in full: rule, tainting step, what to do
84
89
  attesso suggest [--json] rules the ledger's own counts justify
@@ -624,17 +629,62 @@ switch (command) {
624
629
  }
625
630
  break;
626
631
  }
627
- case 'sync': {
632
+ case 'policy': {
628
633
  try {
629
634
  const options = flags(rest);
630
- const requested = options.limit === undefined ? 50 : Number(options.limit);
631
- const result = await sync({
635
+ const subcommand = rest.find((value) => !value.startsWith('--'));
636
+ if (subcommand !== 'pull') throw new Error('policy takes pull');
637
+ requireLedger();
638
+ const url = typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL;
639
+ const key = typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY;
640
+ const result = await pullPolicy({
632
641
  dir: DIR,
633
- url: typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL,
634
- key: typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY,
635
- limit: requested,
636
- dryRun: rest.includes('--dry-run'),
642
+ url,
643
+ key,
644
+ apply: rest.includes('--apply'),
637
645
  });
646
+ if (options.json) {
647
+ console.log(JSON.stringify(result, null, 2));
648
+ } else {
649
+ console.log(`policy: v${result.version} verified with ${result.kid}`);
650
+ console.log(`digest: ${result.digest}`);
651
+ if (result.applied) console.log(`rules: ${result.changed ? 'written' : 'already current'} (${paths(DIR).rules})`);
652
+ else console.log('preview only; re-run with --apply to write rules.json');
653
+ }
654
+ } catch (error) {
655
+ console.error(`policy: ${error.message}`);
656
+ process.exit(2);
657
+ }
658
+ break;
659
+ }
660
+ case 'sync': {
661
+ try {
662
+ const options = flags(rest);
663
+ const requested = options.limit === undefined ? 50 : Number(options.limit);
664
+ const url = typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL;
665
+ const key = typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY;
666
+ if (rest.includes('--watch')) {
667
+ if (rest.includes('--dry-run')) throw new Error('--watch cannot be combined with --dry-run');
668
+ const controller = new AbortController();
669
+ process.on('SIGINT', () => controller.abort());
670
+ process.on('SIGTERM', () => controller.abort());
671
+ console.log('sync: watching the ledger; Ctrl-C stops');
672
+ await watchSync({
673
+ dir: DIR,
674
+ url,
675
+ key,
676
+ limit: requested,
677
+ intervalMs: options.interval === undefined ? 5000 : Number(options.interval),
678
+ signal: controller.signal,
679
+ onResult: (result) => {
680
+ if (result instanceof Error) console.error(`sync: ${result.message}`);
681
+ else if (options.json) console.log(JSON.stringify(result, null, 2));
682
+ else console.log(`sync: ${result.accepted} accepted, ${result.skipped} already present, ${result.metered} metered; last #${result.last_seq}`);
683
+ },
684
+ });
685
+ break;
686
+ }
687
+ const result = await sync({ dir: DIR, url, key, limit: requested, dryRun: rest.includes('--dry-run') });
638
688
  if (options.json) console.log(JSON.stringify(result, null, 2));
639
689
  else if (result.dry_run) console.log(`sync dry run: ${result.entries} decision(s) ready`);
640
690
  else console.log(`sync: ${result.accepted} accepted, ${result.skipped} already present, ${result.metered} metered; last #${result.last_seq}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Bounds: decide an AI agent's next tool call before it runs, with a signed record. Free and unlimited locally, $0.00085 per synced decision.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/connect.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // harness hook, and report a machine-readable result on stdout.
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join } from 'node:path';
6
+ import { dirname, join, resolve } from 'node:path';
7
7
  import { detectHarnesses, writeAgentContext } from './setup.mjs';
8
8
  import { wireHarness } from './install.mjs';
9
9
  import { init, isInitialised, paths } from './store.mjs';
@@ -57,20 +57,24 @@ function legacyModeDirs(home) {
57
57
  }
58
58
  }
59
59
 
60
- function hasAttessoHook(path) {
61
- if (!existsSync(path)) return false;
62
- try {
63
- const text = readFileSync(path, 'utf8');
64
- return /attesso[^\n"]*hook/.test(text);
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- function wiredHarnesses(harnesses) {
60
+ function wiredHarnesses(harnesses, { dir, cwd }) {
61
+ const ledger = resolve(dir);
71
62
  return harnesses.map((harness) => {
72
63
  const candidate = harness.key === 'cline' ? join(harness.path, 'PreToolUse') : harness.path;
73
- return { ...harness, wired: hasAttessoHook(candidate) };
64
+ let wired = false;
65
+ if (existsSync(candidate)) {
66
+ try {
67
+ const text = readFileSync(candidate, 'utf8');
68
+ // A hook for a different ledger is not this project's connection. The
69
+ // legacy wrapper has no --dir, so it keeps counting as wired.
70
+ const match = text.match(/--dir\s+(?:'([^']+)'|"([^"]+)"|(\S+))/);
71
+ const target = match ? match[1] || match[2] || match[3] : null;
72
+ wired = target ? resolve(cwd, target) === ledger : /attesso[^\n"]*hook/.test(text);
73
+ } catch {
74
+ wired = false;
75
+ }
76
+ }
77
+ return { ...harness, wired };
74
78
  });
75
79
  }
76
80
 
@@ -86,7 +90,7 @@ function instructions({ mode, wired, manual }) {
86
90
  export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
87
91
  const initialized = isInitialised(dir);
88
92
  const mode = readMode(dir);
89
- const detected = wiredHarnesses(detectHarnesses({ home, cwd }));
93
+ const detected = wiredHarnesses(detectHarnesses({ home, cwd }), { dir, cwd });
90
94
  const wired = detected.filter((harness) => harness.wired);
91
95
  const manual = detected.filter((harness) => !['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key));
92
96
  return {
@@ -113,8 +117,12 @@ export function connect({
113
117
  if (!CONNECT_MODES.includes(mode)) {
114
118
  throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
115
119
  }
116
- const initialized = isInitialised(dir);
117
- if (write && !initialized) init(dir);
120
+ // Harness configs are global. A relative --dir becomes a hook that points at
121
+ // whatever .attesso happens to be in the agent's current directory, so the
122
+ // ledger path must be absolute before it is written anywhere.
123
+ const ledgerDir = resolve(dir);
124
+ const initialized = isInitialised(ledgerDir);
125
+ if (write && !initialized) init(ledgerDir);
118
126
  const detected = detectHarnesses({ home, cwd }).filter((harness) => !only || harness.key === only);
119
127
  const agentContext = writeAgentContext(cwd, { dryRun: !write });
120
128
  const results = [];
@@ -130,7 +138,7 @@ export function connect({
130
138
  continue;
131
139
  }
132
140
  try {
133
- const result = wireHarness(harness.key, cli, dir, {
141
+ const result = wireHarness(harness.key, cli, ledgerDir, {
134
142
  dryRun: !write,
135
143
  enforce: mode === 'enforce',
136
144
  modeFile: true,
@@ -143,14 +151,14 @@ export function connect({
143
151
  results.push({ key: harness.key, path: harness.path, wired: false, error: error.message });
144
152
  }
145
153
  }
146
- if (write) setModeAll(dir, mode, { home });
154
+ if (write) setModeAll(ledgerDir, mode, { home });
147
155
  const wired = results.filter((result) => result.applied || result.reason === 'already wired');
148
- const effectiveMode = write ? mode : readMode(dir);
156
+ const effectiveMode = write ? mode : readMode(ledgerDir);
149
157
  return {
150
- initialized: isInitialised(dir),
158
+ initialized: isInitialised(ledgerDir),
151
159
  mode: effectiveMode,
152
- connected: isInitialised(dir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
153
- ledger: paths(dir).dir,
160
+ connected: isInitialised(ledgerDir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
161
+ ledger: paths(ledgerDir).dir,
154
162
  harnesses: results,
155
163
  wired: wired.map((result) => result.key),
156
164
  manual: results.filter((result) => result.manual || result.error || result.reason?.includes('merge by hand')).map((result) => result.key),
@@ -0,0 +1,142 @@
1
+ // Hosted policy distribution: fetch a signed rule bundle, verify it against the
2
+ // published Attesso JWKS, and only then let the local engine write rules.json.
3
+ import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
4
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { paths } from './store.mjs';
6
+ import { validateRules } from './rules.mjs';
7
+
8
+ export const POLICY_FORMAT = 'attesso.bounds.policy.v1';
9
+
10
+ const fromB64url = (value) => {
11
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
12
+ return Buffer.from(normalized + '='.repeat((4 - (normalized.length % 4)) % 4), 'base64');
13
+ };
14
+
15
+ export function policyEndpoint(base) {
16
+ const parsed = new URL(base);
17
+ if (parsed.protocol !== 'https:' && parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
18
+ throw new Error('policy url must use HTTPS, except on localhost');
19
+ }
20
+ const path = parsed.pathname.replace(/\/+$/, '').replace(/\/v1\/bounds\/(sync|policy)$/, '');
21
+ parsed.pathname = `${path}/v1/bounds/policy`;
22
+ parsed.search = '';
23
+ parsed.hash = '';
24
+ return parsed.toString();
25
+ }
26
+
27
+ export function jwksEndpoint(base) {
28
+ return new URL('/.well-known/jwks.json', new URL(base)).toString();
29
+ }
30
+
31
+ export async function pullPolicy({
32
+ dir,
33
+ url,
34
+ key,
35
+ apply = false,
36
+ fetchImpl = fetch,
37
+ } = {}) {
38
+ if (!url) throw new Error('policy url is required');
39
+ if (!key) throw new Error('policy API key is required');
40
+ const response = await fetchImpl(policyEndpoint(url), {
41
+ headers: { Authorization: `Bearer ${key}` },
42
+ signal: AbortSignal.timeout(20_000),
43
+ });
44
+ if (response.status === 404) throw new Error('no hosted policy is published');
45
+ if (!response.ok) throw new Error(`policy fetch failed with status ${response.status}`);
46
+ let bundle;
47
+ try {
48
+ bundle = await response.json();
49
+ } catch {
50
+ throw new Error('policy response is invalid');
51
+ }
52
+ if (!bundle || typeof bundle !== 'object' || !bundle.envelope || typeof bundle.envelope !== 'object') {
53
+ throw new Error('policy response is missing its signature');
54
+ }
55
+ if (bundle.envelope.format !== POLICY_FORMAT) {
56
+ throw new Error('policy response has an unexpected format');
57
+ }
58
+
59
+ const jwksResponse = await fetchImpl(jwksEndpoint(url), { signal: AbortSignal.timeout(20_000) });
60
+ if (!jwksResponse.ok) throw new Error(`policy key fetch failed with status ${jwksResponse.status}`);
61
+ let jwks;
62
+ try {
63
+ jwks = await jwksResponse.json();
64
+ } catch {
65
+ throw new Error('policy key response is invalid');
66
+ }
67
+
68
+ const verified = verifyPolicyEnvelope(bundle.envelope, jwks);
69
+ const changed = !existsSync(paths(dir).rules) ||
70
+ JSON.stringify(JSON.parse(readFileSync(paths(dir).rules, 'utf8'))) !== JSON.stringify(verified.rules);
71
+ if (apply) writeRules(dir, verified.rules);
72
+ return {
73
+ version: verified.version,
74
+ kid: verified.kid,
75
+ digest: verified.digest,
76
+ applied: apply,
77
+ changed,
78
+ rules: verified.rules,
79
+ };
80
+ }
81
+
82
+ export function verifyPolicyEnvelope(envelope, jwks) {
83
+ if (!envelope || typeof envelope !== 'object') throw new Error('policy envelope is invalid');
84
+ if (envelope.format !== POLICY_FORMAT) throw new Error('policy envelope has an unexpected format');
85
+ for (const field of ['protected', 'payload', 'signature']) {
86
+ if (typeof envelope[field] !== 'string' || envelope[field] === '') {
87
+ throw new Error('policy envelope is incomplete');
88
+ }
89
+ }
90
+ let header;
91
+ try {
92
+ header = JSON.parse(fromB64url(envelope.protected).toString('utf8'));
93
+ } catch {
94
+ throw new Error('policy envelope header is invalid');
95
+ }
96
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string') {
97
+ throw new Error('policy envelope must use an ES256 key');
98
+ }
99
+ const key = Array.isArray(jwks?.keys) ? jwks.keys.find((item) => item.kid === header.kid) : undefined;
100
+ if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256') {
101
+ throw new Error(`no ES256 verification key for ${header.kid}`);
102
+ }
103
+ let publicKey;
104
+ try {
105
+ publicKey = createPublicKey({ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y }, format: 'jwk' });
106
+ } catch {
107
+ throw new Error(`verification key ${header.kid} is invalid`);
108
+ }
109
+ const valid = verifySignature(
110
+ 'sha256',
111
+ Buffer.from(`${envelope.protected}.${envelope.payload}`, 'utf8'),
112
+ { key: publicKey, dsaEncoding: 'ieee-p1363' },
113
+ fromB64url(envelope.signature),
114
+ );
115
+ if (!valid) throw new Error('policy signature did not verify');
116
+
117
+ let payload;
118
+ try {
119
+ payload = JSON.parse(fromB64url(envelope.payload).toString('utf8'));
120
+ } catch {
121
+ throw new Error('policy payload is invalid');
122
+ }
123
+ if (payload.schema !== POLICY_FORMAT ||
124
+ !Number.isSafeInteger(payload.version) || payload.version < 1 ||
125
+ typeof payload.rules !== 'object' || payload.rules === null) {
126
+ throw new Error('policy payload is invalid');
127
+ }
128
+ const rules = validateRules(payload.rules);
129
+ return {
130
+ version: payload.version,
131
+ rules,
132
+ kid: header.kid,
133
+ digest: `sha256:${createHash('sha256').update(envelope.payload).digest('hex')}`,
134
+ };
135
+ }
136
+
137
+ function writeRules(dir, rules) {
138
+ const file = paths(dir).rules;
139
+ const temporary = `${file}.${process.pid}.tmp`;
140
+ writeFileSync(temporary, `${JSON.stringify(rules, null, 2)}\n`, { mode: 0o600 });
141
+ renameSync(temporary, file);
142
+ }
package/src/setup.mjs CHANGED
@@ -10,10 +10,12 @@ import { join, resolve } from 'node:path';
10
10
  import { argsHashOf } from './rules.mjs';
11
11
 
12
12
  const AGENT_BLOCK = `<!-- attesso:start -->
13
- A veto is active on your tool calls. When a call is refused you get a tool error naming
14
- the rule and the step that tainted the task. Do not retry a refused call unchanged:
15
- read \`attesso explain <seq>\`, take another path, or ask the user whether the rule is
16
- wrong. To see rules the ledger supports, run \`attesso suggest\`.
13
+ Bounds evaluates your tool calls locally and records every decision. In observe mode
14
+ a refusal is recorded but not blocked; in enforce mode it blocks. When a call is
15
+ refused you get a tool error naming the rule and the step that tainted the task.
16
+ Do not retry a refused call unchanged: read \`attesso explain <seq>\`, take another
17
+ path, or ask the user whether the rule is wrong. To see rules the ledger supports,
18
+ run \`attesso suggest\`.
17
19
  <!-- attesso:end -->`;
18
20
 
19
21
  function stripAgentBlock(text) {
package/src/sync.mjs CHANGED
@@ -115,3 +115,37 @@ export async function sync({
115
115
  writeSyncState(dir, lastSeq, deviceId);
116
116
  return { accepted, skipped, metered, last_seq: lastSeq, device_id: deviceId };
117
117
  }
118
+
119
+
120
+ // watchSync keeps the hosted mirror current for a long-running agent session.
121
+ // It never advances the cursor on an error; the next tick retries the same
122
+ // window. Callers stop it with an AbortSignal.
123
+ export async function watchSync({
124
+ dir,
125
+ url,
126
+ key,
127
+ limit = DEFAULT_LIMIT,
128
+ intervalMs = 5000,
129
+ signal,
130
+ onResult = () => {},
131
+ } = {}) {
132
+ if (!Number.isInteger(intervalMs) || intervalMs < 1000 || intervalMs > 300_000) {
133
+ throw new Error('interval must be an integer from 1000 to 300000 milliseconds');
134
+ }
135
+ while (!signal?.aborted) {
136
+ try {
137
+ const result = await sync({ dir, url, key, limit });
138
+ if (result.accepted || result.skipped || result.metered) onResult(result);
139
+ } catch (error) {
140
+ onResult(error);
141
+ }
142
+ if (signal?.aborted) break;
143
+ await new Promise((resolve) => {
144
+ const timer = setTimeout(resolve, intervalMs);
145
+ signal?.addEventListener('abort', () => {
146
+ clearTimeout(timer);
147
+ resolve();
148
+ }, { once: true });
149
+ });
150
+ }
151
+ }
@@ -686,7 +686,10 @@
686
686
  el('agent-sub').textContent = 'Hook active' + seq;
687
687
  el('agent-action').textContent = 'Manage agent';
688
688
  } else {
689
- el('agent-sub').textContent = 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing';
689
+ const wired = Array.isArray(agentData.wired) ? agentData.wired.length : 0;
690
+ el('agent-sub').textContent = wired
691
+ ? 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing'
692
+ : 'No harness wired - connect an agent to start observing';
690
693
  el('agent-action').textContent = 'Connect agent';
691
694
  }
692
695
  renderAgentPanel();
@@ -903,7 +906,11 @@
903
906
  renderAgentStatus();
904
907
  if (body.status?.paths) pathState = body.status.paths;
905
908
  renderPaths();
906
- status.textContent = agentData.connected ? 'Agent connected.' : 'Hook is ' + mode + '.';
909
+ status.textContent = agentData.connected
910
+ ? 'Agent connected.'
911
+ : mode === 'off'
912
+ ? 'Bounds is off; choose Observe and connect again.'
913
+ : 'No agent harness detected; copy the setup command below.';
907
914
  } catch (error) {
908
915
  status.textContent = 'Connect failed: ' + error.message;
909
916
  }
@@ -943,6 +950,7 @@
943
950
  function renderFlow(day, preserveCenter = null) {
944
951
  if (v1) return renderV1Flow(day, preserveCenter);
945
952
  const summary = summarize(day);
953
+ const empty = summary.calls === 0;
946
954
  const width = flowWidth();
947
955
  const inner = width - GUTTER;
948
956
  el('flow-track').style.width = width + 'px';
@@ -953,13 +961,15 @@
953
961
  el('flow-count').textContent = fmt(summary.calls) + ' calls';
954
962
  const hours = el('flow-hours');
955
963
  hours.replaceChildren();
956
- for (let hour = 0; hour <= 24; hour += 1) {
957
- const line = document.createElement('div');
958
- line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
959
- line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
960
- const labelEvery = zoom >= 2 ? 1 : 3;
961
- if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
962
- hours.append(line);
964
+ if (!empty) {
965
+ for (let hour = 0; hour <= 24; hour += 1) {
966
+ const line = document.createElement('div');
967
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
968
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
969
+ const labelEvery = zoom >= 2 ? 1 : 3;
970
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
971
+ hours.append(line);
972
+ }
963
973
  }
964
974
  const lanes = el('flow-lanes');
965
975
  lanes.replaceChildren();
@@ -1008,18 +1018,20 @@
1008
1018
  for (const row of rows) markerBySeq.set(String(row[0]), marker);
1009
1019
  fragments.get(cap).append(marker);
1010
1020
  }
1011
- for (const cap of CAPS) {
1012
- const lane = document.createElement('div');
1013
- lane.className = 'f-lane';
1014
- lane.dataset.capability = cap;
1015
- const label = document.createElement('div');
1016
- label.className = 'f-label';
1017
- label.textContent = cap.toUpperCase();
1018
- const track = document.createElement('div');
1019
- track.className = 'f-track';
1020
- track.append(fragments.get(cap));
1021
- lane.append(label, track);
1022
- lanes.append(lane);
1021
+ if (!empty) {
1022
+ for (const cap of CAPS) {
1023
+ const lane = document.createElement('div');
1024
+ lane.className = 'f-lane';
1025
+ lane.dataset.capability = cap;
1026
+ const label = document.createElement('div');
1027
+ label.className = 'f-label';
1028
+ label.textContent = cap.toUpperCase();
1029
+ const track = document.createElement('div');
1030
+ track.className = 'f-track';
1031
+ track.append(fragments.get(cap));
1032
+ lane.append(label, track);
1033
+ lanes.append(lane);
1034
+ }
1023
1035
  }
1024
1036
  const scroller = el('flow-scroller');
1025
1037
  if (preserveCenter == null) {
@@ -1036,6 +1048,7 @@
1036
1048
 
1037
1049
  function renderV1Flow(day, preserveCenter = null) {
1038
1050
  const summary = summarize(day);
1051
+ const empty = summary.calls === 0;
1039
1052
  const buckets = flowCache.get(day)?.buckets || [];
1040
1053
  const width = flowWidth();
1041
1054
  const inner = width - GUTTER;
@@ -1050,13 +1063,15 @@
1050
1063
  el('flow-count').textContent = fmt(summary.calls) + ' calls';
1051
1064
  const hours = el('flow-hours');
1052
1065
  hours.replaceChildren();
1053
- for (let hour = 0; hour <= 24; hour += 1) {
1054
- const line = document.createElement('div');
1055
- line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
1056
- line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
1057
- const labelEvery = zoom >= 2 ? 1 : 3;
1058
- if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
1059
- hours.append(line);
1066
+ if (!empty) {
1067
+ for (let hour = 0; hour <= 24; hour += 1) {
1068
+ const line = document.createElement('div');
1069
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
1070
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
1071
+ const labelEvery = zoom >= 2 ? 1 : 3;
1072
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
1073
+ hours.append(line);
1074
+ }
1060
1075
  }
1061
1076
  const lanes = el('flow-lanes');
1062
1077
  lanes.replaceChildren();
@@ -1092,18 +1107,20 @@
1092
1107
  }
1093
1108
  fragments.get(cap).append(marker);
1094
1109
  }
1095
- for (const cap of CAPS) {
1096
- const lane = document.createElement('div');
1097
- lane.className = 'f-lane';
1098
- lane.dataset.capability = cap;
1099
- const label = document.createElement('div');
1100
- label.className = 'f-label';
1101
- label.textContent = cap.toUpperCase();
1102
- const track = document.createElement('div');
1103
- track.className = 'f-track';
1104
- track.append(fragments.get(cap));
1105
- lane.append(label, track);
1106
- lanes.append(lane);
1110
+ if (!empty) {
1111
+ for (const cap of CAPS) {
1112
+ const lane = document.createElement('div');
1113
+ lane.className = 'f-lane';
1114
+ lane.dataset.capability = cap;
1115
+ const label = document.createElement('div');
1116
+ label.className = 'f-label';
1117
+ label.textContent = cap.toUpperCase();
1118
+ const track = document.createElement('div');
1119
+ track.className = 'f-track';
1120
+ track.append(fragments.get(cap));
1121
+ lane.append(label, track);
1122
+ lanes.append(lane);
1123
+ }
1107
1124
  }
1108
1125
  const scroller = el('flow-scroller');
1109
1126
  if (preserveCenter == null) {