harlay 0.1.1 → 0.1.3

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.
@@ -87,6 +87,29 @@ button { font: inherit; color: inherit; background: none; border: 0; text-align:
87
87
  .label { display: flex; align-items: center; gap: 7px; padding: 8px 16px 6px; font: 500 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--fg3); }
88
88
  .label::before { content: ""; width: 5px; height: 5px; background: var(--accent); }
89
89
 
90
+ /* usage */
91
+ .usage { padding: 0 8px 4px; }
92
+ .usage .label { padding: 2px 8px 6px; }
93
+ .u-row { display: flex; gap: 10px; padding: 6px 8px; }
94
+ .u-name { flex: none; width: 86px; font-size: 12px; font-weight: 600; padding-top: 1px; }
95
+ .u-body { flex: 1; min-width: 0; }
96
+ .u-win { display: flex; align-items: center; gap: 8px; height: 18px; }
97
+ .u-lbl { flex: none; width: 34px; font: 10.5px var(--mono); color: var(--fg3); }
98
+ .u-bar { flex: 1; height: 5px; border-radius: 999px; background: rgba(var(--ink-rgb), .08); overflow: hidden; }
99
+ .u-bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
100
+ .u-bar i.warm { background: var(--warn); }
101
+ .u-bar i.hot { background: var(--err); }
102
+ .u-pct { flex: none; width: 38px; text-align: right; font: 500 11px var(--mono); color: var(--fg); }
103
+ .u-reset { flex: none; width: 92px; font: 10.5px var(--mono); color: var(--fg3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
104
+ .u-meta { margin-top: 2px; font-size: 11px; color: var(--fg3); }
105
+ .link.inline { margin: 0 0 0 6px; font-size: 11px; }
106
+ .switch:disabled { opacity: .4; }
107
+
108
+ .ask { margin: 0 8px 8px; padding: 11px 12px; border-radius: 10px; background: var(--card); border: 1px solid rgba(var(--accent-rgb), .35); }
109
+ .ask-title { font-size: 12.5px; font-weight: 600; }
110
+ .ask-body { margin-top: 3px; font-size: 11.5px; color: var(--fg2); }
111
+ .ask-btns { display: flex; gap: 6px; margin-top: 9px; }
112
+
90
113
  /* sessions */
91
114
  .label.group { padding: 10px 8px 4px; }
92
115
  .label.group:first-child { padding-top: 2px; }
@@ -163,6 +186,7 @@ kbd { margin-right: 6px; padding: 1px 5px; border-radius: 4px; border: 1px solid
163
186
  .md h3 { margin: 2px 0 8px; font: 500 16px/1.25 var(--serif); color: var(--fg); }
164
187
  .md h4 { margin: 14px 0 5px; font-size: 12.5px; font-weight: 600; color: var(--fg); }
165
188
  .md p { margin: 0 0 7px; }
189
+ .md p.muted { font-size: 12px; color: var(--fg3); }
166
190
  .md strong { color: var(--fg); font-weight: 600; }
167
191
  .md code { padding: 0 4px; border-radius: 4px; background: var(--sunk); border: 1px solid var(--line); font: 11px var(--mono); color: var(--fg); word-break: break-word; }
168
192
  .md pre { margin: 0 0 8px; padding: 8px 10px; border-radius: 8px; background: var(--sunk); border: 1px solid var(--line); font: 11px/1.45 var(--mono); color: var(--fg); white-space: pre-wrap; word-break: break-word; }
@@ -122,15 +122,56 @@ function groupSessions(sessions) {
122
122
  return { attention, projects: [...projects.values()] };
123
123
  }
124
124
 
125
+ /** A bar's fill. The page's CSP forbids style attributes, so the width is set through the CSSOM. */
126
+ function fill(pct, cls) {
127
+ const el = h('i', { class: cls });
128
+ el.style.width = `${Math.max(2, Math.min(100, pct))}%`;
129
+ return el;
130
+ }
131
+
132
+ /** Per-agent usage: a bar per window (≈ marks Harlay's estimate), then reset time, pace and notes. */
133
+ function renderUsage() {
134
+ const rows = model.usage ?? [];
135
+ if (!rows.length) return null;
136
+ const ask = model.askClaudeBridge
137
+ ? h('div', { class: 'ask' },
138
+ h('p', { class: 'ask-title' }, 'Show Claude’s exact usage?'),
139
+ h('p', { class: 'ask-body' }, 'Claude Code in Terminal can pass its real usage to Harlay through its status line. Harlay adds one line to ~/.claude/settings.json; a status line you already have keeps working.'),
140
+ h('div', { class: 'ask-btns' },
141
+ h('button', { class: 'btn primary', onclick: () => api.act({ type: 'answerClaudeBridge', allow: true }) }, 'Allow'),
142
+ h('button', { class: 'btn', onclick: () => api.act({ type: 'answerClaudeBridge', allow: false }) }, 'Not now')))
143
+ : null;
144
+ return h('div', { class: 'usage' },
145
+ h('div', { class: 'label' }, 'Usage'),
146
+ ask,
147
+ rows.map((r) => {
148
+ const level = (pct) => (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '');
149
+ const meta = [r.windows.length === 1 ? r.windows[0].resets : null, r.pace, r.note].filter(Boolean);
150
+ return h('div', { class: 'u-row' },
151
+ h('div', { class: 'u-name' }, r.label),
152
+ h('div', { class: 'u-body' },
153
+ r.windows.map((w) => h('div', { class: 'u-win', title: w.exact ? 'Reported by the agent' : 'Harlay’s estimate from your session logs' },
154
+ h('span', { class: 'u-lbl' }, w.label),
155
+ h('span', { class: 'u-bar' }, fill(w.pct, level(w.pct))),
156
+ h('span', { class: 'u-pct' }, `${w.exact ? '' : '≈'}${w.pct}%`),
157
+ r.windows.length > 1 && w.resets ? h('span', { class: 'u-reset' }, w.resets.replace('resets ', '')) : null)),
158
+ meta.length || r.usagePage
159
+ ? h('div', { class: 'u-meta' }, meta.join(' · '),
160
+ r.usagePage ? h('button', { class: 'link inline', onclick: () => api.act({ type: 'openCursorUsage' }) }, 'Open usage page') : null)
161
+ : null));
162
+ }));
163
+ }
164
+
125
165
  function renderHome() {
126
166
  const { sessions } = model;
127
167
  if (!sessions.length) {
128
- $('#home').replaceChildren(renderHero(), h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
168
+ $('#home').replaceChildren(renderHero(), renderUsage(), h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
129
169
  return;
130
170
  }
131
171
  const { attention, projects } = groupSessions(sessions);
132
172
  $('#home').replaceChildren(
133
173
  renderHero(),
174
+ renderUsage(),
134
175
  h('div', { class: 'scroll' },
135
176
  attention.length ? [h('div', { class: 'label group attn' }, 'Needs attention'), attention.map((s) => renderSession(s, true))] : null,
136
177
  projects.map((p) => [
@@ -144,10 +185,10 @@ function seg(options, current, onPick) {
144
185
  h('button', { 'aria-pressed': String(o.value === current), disabled: o.disabled, onclick: () => onPick(o.value) }, o.label)));
145
186
  }
146
187
 
147
- function toggleRow(label, hint, checked, action) {
188
+ function toggleRow(label, hint, checked, action, disabled = false) {
148
189
  return h('div', { class: 'set-row' },
149
190
  h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
150
- h('button', { class: 'switch', role: 'switch', 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
191
+ h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
151
192
  }
152
193
 
153
194
  function renderSettings() {
@@ -172,7 +213,16 @@ function renderSettings() {
172
213
  ], settings.theme, (v) => api.act({ type: 'setTheme', theme: v })),
173
214
  h('div', { class: 'label' }, 'Behavior'),
174
215
  toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
175
- toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' })));
216
+ toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' }),
217
+ h('div', { class: 'label' }, 'Usage'),
218
+ toggleRow(
219
+ 'Exact Claude usage',
220
+ settings.claudeBridge === 'unavailable'
221
+ ? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
222
+ : 'Claude Code in Terminal passes its real usage to Harlay through its status line. Your own status line keeps working; turning this off restores it.',
223
+ settings.claudeBridge === 'on',
224
+ { type: 'toggleClaudeBridge' },
225
+ settings.claudeBridge === 'unavailable')));
176
226
  }
177
227
 
178
228
  /** Inline markdown: `code` and **bold**; everything else stays plain text. */
@@ -240,12 +290,73 @@ function markdown(src) {
240
290
  return out;
241
291
  }
242
292
 
293
+ /** Headings for the blocks of an inline handoff prompt (see packages/core/src/brief/prompt.ts). */
294
+ const BRIEF_BLOCKS = {
295
+ original_goal: 'Original goal',
296
+ rules_from_user: 'Rules from the user',
297
+ recent_user_messages: 'Recent messages (oldest first)',
298
+ where_it_stopped: 'Where it stopped',
299
+ files: 'Files',
300
+ last_failure: 'Last failure',
301
+ };
302
+
303
+ /** `key="value"` pairs of the opening `<handoff …>` line (the prompt never puts `"` inside a value). */
304
+ function tagAttrs(line) {
305
+ const out = {};
306
+ for (const m of line.matchAll(/([a-z_]+)="([^"]*)"/g)) out[m[1]] = m[2];
307
+ return out;
308
+ }
309
+
310
+ /**
311
+ * HANDOFF.md as Harlay writes it now: the tagged launch prompt. Block text is shown as written, never parsed as
312
+ * Markdown, so a line starting with ``` is plain text; the failure output is one verbatim block.
313
+ */
314
+ function handoffBrief(src) {
315
+ const lines = src.replace(/\r\n/g, '\n').trim().split('\n');
316
+ const out = [];
317
+ let block = null;
318
+ let list = null;
319
+ let pre = null;
320
+ const close = () => {
321
+ if (pre) out.push(h('pre', {}, pre.join('\n')));
322
+ block = list = pre = null;
323
+ };
324
+ for (const line of lines) {
325
+ const t = line.trim();
326
+ let m;
327
+ if (!block && /^<handoff\s/.test(t)) {
328
+ const a = tagAttrs(t);
329
+ out.push(h('h3', {}, `Handoff from ${a.from || 'another agent'}${a.task ? ` · ${a.task}` : ''}`));
330
+ } else if (!block && (m = /^<([a-z_]+)(?:\s[^>]*)?>$/.exec(t)) && Object.hasOwn(BRIEF_BLOCKS, m[1])) {
331
+ block = m[1];
332
+ out.push(h('h4', {}, BRIEF_BLOCKS[block]));
333
+ if (block === 'last_failure') pre = [];
334
+ } else if ((m = /^<\/([a-z_]+)>$/.exec(t)) && (m[1] === block || (!block && m[1] === 'handoff'))) {
335
+ close();
336
+ } else if (pre) {
337
+ pre.push(line);
338
+ } else if (t === '') {
339
+ list = null;
340
+ } else if (!block) {
341
+ out.push(h('p', { class: 'muted' }, t));
342
+ } else if (t.startsWith('- ')) {
343
+ if (!list) out.push((list = h('ul', {})));
344
+ list.append(h('li', {}, t.slice(2)));
345
+ } else {
346
+ list = null;
347
+ out.push(h('p', {}, t));
348
+ }
349
+ }
350
+ close();
351
+ return out;
352
+ }
353
+
243
354
  function renderBrief() {
244
355
  const session = model.sessions.find((s) => s.key === brief.key);
245
356
  let body;
246
357
  if (brief.loading) body = h('div', { class: 'busy pad' }, h('span', { class: 'spin' }), 'Building the brief…');
247
358
  else if (brief.error) body = h('p', { class: 'empty' }, `Couldn’t build the brief: ${brief.error}`);
248
- else body = h('div', { class: 'md' }, markdown(brief.text));
359
+ else body = h('div', { class: 'md' }, brief.text.trim().startsWith('<handoff ') ? handoffBrief(brief.text) : markdown(brief.text));
249
360
  const ready = !brief.loading && !brief.error;
250
361
  $('#brief').replaceChildren(
251
362
  h('div', { class: 'hero' }, h('h1', {}, 'Brief'), h('p', { class: 'one-line' }, brief.title)),
@@ -353,7 +464,12 @@ function demoApi() {
353
464
  { tool: 'codex', label: 'Codex', mode: 'app', app: true, terminal: true },
354
465
  { tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
355
466
  ];
356
- const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system' };
467
+ const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
468
+ const usage = [
469
+ { tool: 'claude-code', label: 'Claude Code', windows: [{ label: '5h', pct: 72, exact: false, resets: 'resets 4:10 PM' }], pace: '≈ 38 min to limit at this pace', note: '≈ estimated from your session logs', usagePage: false },
470
+ { tool: 'codex', label: 'Codex', windows: [{ label: '5h', pct: 18, exact: true, resets: 'resets in 52m' }, { label: 'Week', pct: 41, exact: true, resets: 'resets Fri 7:12 PM' }], pace: null, note: '1 free limit reset available', usagePage: false },
471
+ { tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
472
+ ];
357
473
  const targets = (from) => {
358
474
  const order = [settings.defaultTarget, ...tools.map((t) => t.tool).filter((t) => t !== settings.defaultTarget)];
359
475
  return order.filter((t) => t !== from).map((t) => {
@@ -370,9 +486,46 @@ function demoApi() {
370
486
  ];
371
487
  let onModel = () => {};
372
488
  let onBrief = () => {};
373
- const sampleBrief = (title) => `---\nharlay: 1\n---\n\n# Continue: ${title}\n\n> You are continuing a coding task that another AI agent started.\n\n## 1. Goal (user's words)\n> Move auth from session cookies to JWT across \`api/\`.\n\n## 2. Next step\nMigrate the last 3 route handlers in \`api/routes/\`.\n\n## 4. Progress\n- [x] Added \`src/lib/jwt.ts\`\n- [ ] Migrate \`billing.ts\`\n\n\`\`\`text\nnpm test -- auth\n\`\`\`\n`;
489
+ const sampleBrief = (title) => [
490
+ `<handoff from="Claude Code" reason="usage_limit" task="${title.replace(/"/g, "'")}">`,
491
+ "You are taking over a coding session that Claude Code could not finish (it hit a usage limit). What you need to start is below; don't re-read the repo to rebuild it.",
492
+ '',
493
+ '<rules_from_user>',
494
+ "- Don't change the public /v1 API shape.",
495
+ '- Never push to main.',
496
+ '</rules_from_user>',
497
+ '',
498
+ '<recent_user_messages oldest_first="true">',
499
+ '- Move auth from session cookies to JWT across api/.',
500
+ '- Also migrate billing.ts before you run the tests.',
501
+ '</recent_user_messages>',
502
+ '',
503
+ '<where_it_stopped>',
504
+ '```bash npm test -- auth ``` still fails on the refresh test. Next I will migrate the last 3 route handlers in api/routes/.',
505
+ 'Open todos: [in progress] Migrate billing.ts; [pending] Delete the legacy session store',
506
+ '</where_it_stopped>',
507
+ '',
508
+ '<files>',
509
+ 'Touched: src/lib/jwt.ts, api/routes/auth.ts',
510
+ 'Uncommitted: src/lib/jwt.ts (added, +42 -0), api/routes/auth.ts (modified, +12 -30)',
511
+ '</files>',
512
+ '',
513
+ '<last_failure>',
514
+ '$ npm test -- auth (exit 1)',
515
+ 'FAIL auth.test.ts > refresh token rotates',
516
+ ' expected 401, received 200',
517
+ '</last_failure>',
518
+ '',
519
+ "Only if you need more: .harlay/handoffs/1005-1742-migrate-auth-to-jwt/transcript.md (search it, don't read it all) and .harlay/handoffs/1005-1742-migrate-auth-to-jwt/diff.patch.",
520
+ '</handoff>',
521
+ '',
522
+ 'Continue from the most recent user message. Start with the next concrete step.',
523
+ '',
524
+ ].join('\n');
374
525
  const emit = () => onModel({
375
526
  sessions: sessions.map((s) => ({ ...s, targets: targets(s.from) })),
527
+ usage,
528
+ askClaudeBridge: !settings.asked,
376
529
  settings: { ...settings },
377
530
  tools: tools.map((t) => ({ ...t })),
378
531
  hotkey: '⌥H',
@@ -391,6 +544,11 @@ function demoApi() {
391
544
  if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
392
545
  if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
393
546
  if (a.type === 'setTheme') settings.theme = a.theme;
547
+ if (a.type === 'answerClaudeBridge') {
548
+ settings.asked = true;
549
+ settings.claudeBridge = a.allow ? 'on' : 'off';
550
+ }
551
+ if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
394
552
  if (a.type === 'previewBrief') {
395
553
  const s = sessions.find((x) => x.key === a.key);
396
554
  setTimeout(() => onBrief({ key: s.key, title: s.title, path: `${s.cwd}/.harlay/HANDOFF.md`, text: sampleBrief(s.title) }), 600);