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.
- package/app/dist/main.cjs +1299 -399
- package/app/renderer/popover.css +24 -0
- package/app/renderer/popover.js +165 -7
- package/dist/harlay.mjs +569 -337
- package/package.json +1 -1
package/app/renderer/popover.css
CHANGED
|
@@ -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; }
|
package/app/renderer/popover.js
CHANGED
|
@@ -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) =>
|
|
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);
|