SLOPSHOPPER

veille

Veille : un panneau, quatre onglets. Alertes : un script sans tokens (tâche Windows ClaudeVeille) fait le tour des conversations, PR/MR et CI selon un mode…

panebandcommandprocesstimer
v0.4.0MITupdated 2026-10-22julian-passebecq/claude-mod/mods/veille
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · veille
│ ┃ Veille ✕ › fix the failing auth test and add an audit log call │ ┃ [ Alertes ] [ Fil ] [ À archiver ] [ Export │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ Veille · Mode rien ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Peut : rien ⏺ Bash(bun test) │ ┃ │ Dernier tour : jamais (veille éteinte) ⎿ 3 pass, 1 fail │ ┃ │ Coordinateur : aucun │ ┃ │ [ Tour maintenant ] [ Rafraîchir ] [ Nuit ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Alertes (0) │ › /veille │ ┃ │ Rien à signaler. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Actions et événements │ │ ┃ │ — │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Veille
[ Alertes ] [ Fil ] [ À archiver ] [ Export eta ] ╭──────────────────────────────────────────────────────────╮ │ Veille · Mode rien │ │ │ │ Peut : rien │ │ Dernier tour : jamais (veille éteinte) │ │ Coordinateur : aucun │ │ [ Tour maintenant ] [ Rafraîchir ] [ Nuit ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Alertes (0) │ │ Rien à signaler. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Actions et événements │ │ — │ ╰──────────────────────────────────────────────────────────╯
README

veille

« Veille », one entry of the Mods bar: one pane, four tabs. Read-only, no model call, nothing added to a prompt.

TabCommandWhat it showsReads
Alertes/veille or /veille alertesthe watcher's mode and its buttons, alerts (severity, since when, link, Ack), last tour (red when two fires were missed), actions with their reason~/.claude/veille/state.json, heartbeat.json, acks.jsonl
Fil/veille filevery conversation's last message, by project, lead → sub-conversations~/.claude/veille/fil.json
À archiver/veille archiverfinished conversations the VEILLE conversation may archive in one batch, « Copier la liste des ids »; the command also prints the list~/.claude/veille/archive.json
Export eta/veille exportexport-eta's finished batch, why it is ready, its conversations, Exporter vers eta, the last exports~/.claude/export-eta/pending.json, state.json

/veille close closes the pane. /veille mode <x>, /veille ack <clé|all>, /veille coordinateur, /veille tour, /veille nuit, /veille jour run the watcher's own commands.

Night and day

/veille nuit (or the « Nuit » button of the Alertes tab) sets the mode night.mode (nuit-active) and records night_started in ~/.claude/veille/config.json. /veille jour (« Jour ») sets night.day_mode (légère) and writes the night summary to ~/.claude/veille/night-summary.json from events.jsonl: merges, failures, what waited on Julian (once per alert), wakes, since the night started (else the last fallback_h hours). The command answers with it and the Alertes tab shows it for show_h hours. The Cockpit's « Bilan avant la nuit » screen can read the same files.

Safe wake

Never claude -p --resume (16 Oct 22:45: it turned effort low into medium and acceptEdits into bypass). A wake starts a fresh claude -p with --model, --effort and --permission-mode read from the target's session file (%APPDATA%/Claude/claude-code-sessions/**/local_*.json, wake.sessions_dir moves it), in the target's folder, with the ≤ 10-line brief and CLAUDE_CODE_SUBAGENT_MODEL set to the same model (16 Oct: --model haiku with that variable set to Sonnet ran on Sonnet). Missing or unknown settings: no wake. Its stream-json output goes to ~/.claude/veille/wakes/; every fire reads the result and checks, in the new session's transcript, the model, effort and permission mode it really ran with (wakes.json, shown at the top of the Fil: conforme / écart / erreur). A gap is a wake_failed (health record, so Problèmes). node watcher/veille.mjs wake-test [--model m --effort e --permission-mode p] runs the same wake on a throwaway target and exits 0 only when all three match; it passed on 16 Oct (haiku low plan, haiku low acceptEdits, sonnet medium acceptEdits), so wake.enabled is true. Wakes still happen only in the modes that list wake (active, nuit-active), within their token budget.

Deadlines and vendored mods

Each tour reads deadlines.file (~/.claude/a-faire/claude-control.md): an open line - [ ] YYYY-MM-DD · prio · quoi · … due within 7 days is an alert (J-7), within 1 day a new alert (J-1, notifies again); overdue stays at J-1. Every vendor_check.every_days (7) the tour compares each vendored mod's plugin.json version (repo checkout recorded by veille install) with its upstream latest release (gh api, read only); a newer release is an info alert until the copy is updated.

Export eta tab

« Exporter vers eta » runs export-eta's exporter (export-eta/exporter/export.mjs run --mode batch, beside this mod), the same argv as the watcher's export alert (lib/export-view.mjs EXPORT_RUN): masking, fresh worktree of the eta repo, PR, squash merge. The Veille pushes nothing else. « Rafraîchir » runs its pending command. « Ouvrir Export eta » runs /export-eta for its selection and history tabs.

/export-eta keeps working: the export-eta mod registers it while loaded. When it is not loaded, veille registers /export-eta [lot|close] as an alias of this tab (checked with $.command.list() 3 s after the session starts).

One « attend Julian » count

The rule is lib/core/veille.mjs attendJulian: open alerts marked julian, not acked (* acks all). The watcher (status) imports it. A hooks module may import only its own files and claude-code (claude plugin validate refuses node:fs, which lib/core/veille.mjs imports for readVeille), so the pane uses the same rule in hooks/view.mjs julianCount, and tests/hub.test.mjs checks both give the same number on every fixture. The Fil's own julian field equals filWaiting of the same core file. The pane shows the count once, in the tab bar; the Fil header uses the same number. The line above the prompt is off by default (option band: the Jauge shows the count).

lib/core.mjs takes « Ça t'attend » from lib/core/waiting.mjs (synced by scripts/sync-libs.mjs), no longer from factory's folder.

Health record

Every watcher run that looks at the world (fire, tour, julian, fil, archiver, test-mail) writes ~/.claude/mods-health/veille.json through lib/core/health.mjs (writeHealth): error is null on a clean run, else one line joining this run's failures (tour_failed, fil_failed, export_failed, mail_failed, merge_failed, archive_failed, problemes_failed, toast_failed, wake_failed, a crash), secrets scrubbed. Santé and the Problèmes register read it: the problemes collector runs at the end of every tour, failed tours included (before 16 Oct a failed tour skipped it, so a scanner that kept failing never reached Problèmes). The record of a run is written when it exits, so it reaches Problèmes at the next tour.

Files

hooks/register.mjs (pane, commands), hooks/view.mjs (Alertes view model, copied into cockpit), hooks/fil-view.mjs (Fil and À archiver), lib/export-view.mjs (Export eta view model), watcher/ (the scheduled script and its copies to ~/.claude/veille/bin), lib/night.mjs (safe wake, night summary, deadlines, vendored versions), veille.config.json (modes, labels, export, night, deadlines, vendor_check blocks). Tests: node --test mods/veille/tests/*.test.mjs, claude plugin test mods/veille.

Source 7 files
hooks/register.mjs 410 lines
1// veille - the pane « Veille » (one pane, four tabs) and an optional one-line band entry. Read-only view of
2// ~/.claude/veille (written by the watcher, the scheduled task ClaudeVeille) and of export-eta's ~/.claude/export-eta
3// pending.json / state.json; the buttons run the watcher's own commands (mode, ack, tour, coordinator) or export-eta's
4// exporter, so there is one code path each. Nothing here blocks, rewrites or adds text to a prompt; no model call.
5//   /veille [alertes | fil | archiver | export | nuit | jour | mode <x> | ack <clé|all> | coordinateur | tour | close]
6// nuit / jour run the watcher's commands (mode nuit-active; mode légère + the night summary, night-summary.json).
7// Tabs: Alertes · Fil (every conversation's last message, fil-view.mjs) · À archiver (archive.json) · Export eta
8// (export-eta's batch, lib/export-view.mjs). /export-eta is registered here as an alias only when no other mod serves it.
9import { deepMerge, modeName } from '../lib/modes.mjs'
10import { markAllRead, markRead } from '../lib/fil.mjs'
11import { archiveConfig, archiveText } from '../lib/archive.mjs'
12import { EXPORT_PENDING, EXPORT_RUN, exportTabLabel, exportView } from '../lib/export-view.mjs'
13import { viewModel, bandLine, eventText, ago } from './view.mjs'
14import { drawArchiveTab, drawFil, filLabels, nightLabels } from './fil-view.mjs'
15
16let options = {}
17const D = { cfg: null, state: null, heartbeat: null, acks: [], open: false, busy: '', msg: '', at: 0,
18  tab: 'alertes', fil: null, archive: null, filLoaded: false, read: null, filUi: { filter: 'tout', project: '', openProj: {}, openConv: {}, archiveOpen: false },
19  pending: null, exportState: null, aliasExport: false, wakes: null, night: null }
20
21export const TABS = ['alertes', 'fil', 'archiver', 'export']
22const TAB_OF = { alertes: 'alertes', alerts: 'alertes', fil: 'fil', archiver: 'archiver', archive: 'archiver', export: 'export', 'export-eta': 'export', eta: 'export' }
23
24async function ehome($) {
25  const o = await $.env.get('EXPORT_ETA_HOME')
26  return o ? o.replace(/\\/g, '/') : (await homeDir($)) + '/.claude/export-eta'
27}
28
29// Opens the pane on one tab, loading what that tab reads.
30async function openTab($, tab) {
31  D.tab = tab
32  D.open = true
33  if (tab === 'fil' || tab === 'archiver') await loadFil($)
34  if (tab === 'export') await loadExport($)
35  await $.ui.open({ id: 'veille', title: 'Veille', focus: true, closeOnEscape: true })
36  $.ui.invalidate('ui.render')
37}
38
39// export-eta's files (written by its exporter): the batch and the export history. Read only.
40async function loadExport($) {
41  const h = await ehome($)
42  D.pending = await readJson($, h + '/pending.json')
43  D.exportState = await readJson($, h + '/state.json')
44}
45
46// Fil: the digest file (read on demand only), the « read » marks and the folds, kept in the mod's $.store.
47async function loadFil($) {
48  D.fil = await readJson($, (await vhome($)) + '/fil.json')
49  D.archive = await readJson($, (await vhome($)) + '/archive.json')
50  D.filLoaded = true
51  try { D.read = (await $.store.get('fil.read')) || null } catch { D.read = null }
52  try { const u = await $.store.get('fil.ui'); if (u && typeof u === 'object') D.filUi = { ...D.filUi, ...u, openConv: {} } } catch { /* defaults */ }
53  if (!D.read || !D.read.since) { D.read = markAllRead(Date.now()); try { await $.store.set('fil.read', D.read) } catch { /* memory only */ } }
54}
55
56// Hands a claude:// link to the OS (its handler is the desktop app). Windows: url.dll's FileProtocolHandler, no shell.
57async function openUrl($, url) {
58  if (!/^claude:\/\/claude\.ai\/epitaxy\/[\w-]+$/.test(String(url || ''))) return false
59  let win = false
60  try { win = (await $.env.get('OS')) === 'Windows_NT' } catch { win = false }
61  const argv = win ? ['rundll32.exe', 'url.dll,FileProtocolHandler', url] : ['xdg-open', url]
62  try { const r = await $.process.run(argv, { timeoutMs: 10000 }); return r.exitCode === 0 } catch { return false }
63}
64
65async function saveFilUi($) {
66  try { await $.store.set('fil.ui', { filter: D.filUi.filter, project: D.filUi.project, openProj: D.filUi.openProj, archiveOpen: D.filUi.archiveOpen }) } catch { /* memory only */ }
67}
68
69const root = ($) => $.plugin.root.replace(/\\/g, '/')
70
71async function homeDir($) {
72  return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
73}
74
75async function vhome($) {
76  const o = await $.env.get('VEILLE_HOME')
77  return o ? o.replace(/\\/g, '/') : (await homeDir($)) + '/.claude/veille'
78}
79
80async function readText($, p) {
81  try { return (await $.fs.exists(p)) ? await $.fs.read(p) : null } catch { return null }
82}
83
84async function readJson($, p) {
85  const t = await readText($, p)
86  if (!t) return null
87  try { return JSON.parse(t.replace(/^/, '')) } catch { return null }
88}
89
90async function load($) {
91  const h = await vhome($)
92  const base = (await readJson($, root($) + '/veille.config.json')) || {}
93  const user = (await readJson($, h + '/config.json')) || {}
94  D.cfg = deepMerge(base, user)
95  D.state = await readJson($, h + '/state.json')
96  D.heartbeat = await readJson($, h + '/heartbeat.json')
97  D.wakes = await readJson($, h + '/wakes.json')
98  D.night = await readJson($, h + '/night-summary.json')
99  const acks = (await readText($, h + '/acks.jsonl')) || ''
100  D.acks = acks.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l).key } catch { return null } }).filter(Boolean)
101  try { D.at = await $.clock.now() } catch { D.at = Date.now() }
102}
103
104async function runWatcher($, args, timeoutMs = 30000) {
105  const node = (D.cfg && D.cfg.node) || 'node'
106  try {
107    const r = await $.process.run([node, root($) + '/watcher/veille.mjs', ...args], { timeoutMs })
108    D.msg = ((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim().split('\n').pop().replace(/^veille: /, '').slice(0, 200)
109    return r.exitCode === 0
110  } catch (err) {
111    D.msg = String(err && err.message ? err.message : err).slice(0, 200)
112    return false
113  }
114}
115
116async function act($, label, args, timeoutMs) {
117  if (D.busy) return
118  D.busy = label
119  $.ui.invalidate('ui.render')
120  try { await runWatcher($, args, timeoutMs) } finally { D.busy = ''; await load($); $.ui.invalidate('ui.render') }
121}
122
123// An alert may carry `run`: a script of a sibling mod (path relative to the mods folder, e.g. export-eta's exporter)
124// and its arguments; its button runs it with node. Only a plain `<mod>/<path>.mjs` is accepted (no `..`).
125async function runSibling($, argv) {
126  if (D.busy || !Array.isArray(argv) || !/^[a-z0-9-]+\/[a-z0-9/_-]+\.mjs$/.test(String(argv[0] || '')) || String(argv[0]).includes('..')) return
127  D.busy = 'run'
128  $.ui.invalidate('ui.render')
129  try {
130    const r = await $.process.run([(D.cfg && D.cfg.node) || 'node', `${root($)}/../${argv[0]}`, ...argv.slice(1).map(String)], { timeoutMs: 600000 })
131    D.msg = ((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim().split('\n').pop().slice(0, 200)
132  } catch (err) {
133    D.msg = String(err && err.message ? err.message : err).slice(0, 200)
134  } finally { D.busy = ''; await load($); $.ui.invalidate('ui.render') }
135}
136
137function draw(el, cfg, vm, h) {
138  const colors = cfg.colors || {}
139  const L = cfg.labels || {}
140  const NL = nightLabels(cfg)
141  const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: 'wrap', ...(o.tone && colors[o.tone] ? { color: colors[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
142  const row = (parts, o = {}) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, ...o, children: parts.filter(Boolean) })
143  const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
144  const box = (title, parts, tone) => el.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: colors[tone || 'dim'], paddingX: 1, marginBottom: 1,
145    children: [title ? T(title, { bold: true, tone }) : null, ...parts].filter(Boolean) })
146  const btn = (label, onPress, primary) => el.Button({ children: [label], variant: primary ? 'primary' : 'secondary', onPress })
147  const link = (href, label) => (href ? (el.Link ? el.Link({ href, children: [label] }) : T(label, { dim: true })) : null)
148
149  const head = box(`${L.title || 'Veille'} · ${L.mode || 'Mode'} ${vm.mode.label || vm.mode.name}`, [
150    T(vm.mode.help || ''),
151    T(`${L.may || 'Peut'} : ${vm.may.join(', ') || 'rien'}`, { dim: true }),
152    row(vm.modes.map((m) => btn(m.label, () => h.mode(m.id), m.current))),
153    T(vm.watcher.text, { tone: vm.watcher.tone, bold: vm.watcher.tone === 'bad' }),
154    T(`${L.coordinator || 'Coordinateur'} : ${vm.coordinator || L.no_coordinator || 'aucun'}${vm.mail ? ' · ' + vm.mail : ''}`, { dim: true, tone: /non configuré|erreur/.test(vm.mail) ? 'warn' : undefined }),
155    vm.error ? T(`erreur : ${vm.error}`, { tone: 'bad' }) : null,
156    vm.reviewsErrors.length ? T(`PR/MR non lues : ${vm.reviewsErrors.join(' ; ')}`, { tone: 'warn' }) : null,
157    row([btn(h.busy === 'tour' ? 'Tour en cours…' : 'Tour maintenant', h.tour), btn('Rafraîchir', h.refresh), vm.alerts.some((a) => !a.acked) ? btn('Ack tout', () => h.ack('all')) : null,
158      h.nightOn ? btn(h.busy === 'jour' ? '…' : NL.day_button || 'Jour', h.day, true) : btn(h.busy === 'nuit' ? '…' : NL.night_button || 'Nuit', h.night)]),
159    h.nightOn ? T(String(NL.night_on || 'Nuit active depuis {time}').replace('{time}', hmOf(h.nightOn)), { tone: 'info', bold: true }) : null,
160    h.msg ? T(h.msg, { dim: true }) : null,
161  ], vm.watcher.tone === 'bad' ? 'bad' : 'dim')
162
163  const items = vm.alerts.length ? vm.alerts.map((a) => col([
164    row([
165      T(`● ${(L.severity || {})[a.severity] || a.severity}`, { tone: a.acked ? 'dim' : a.severity, bold: !a.acked }),
166      T(a.what, a.acked ? { dim: true } : {}),
167    ]),
168    row([
169      a.title ? T(a.title, { dim: true }) : null,
170      T(`depuis ${ago(h.now - (a.since || a.first_seen || h.now))}`, { dim: true }),
171      link(a.session_link, 'conversation'), link(a.link && a.link !== a.session_link ? a.link : null, a.pr || 'lien'),
172      a.run && !a.acked ? btn(a.run_label || 'Lancer', () => h.run(a.run), true) : null,
173      a.acked ? T(L.acked || 'vu', { dim: true }) : btn(L.ack || 'Ack', () => h.ack(a.key)),
174    ]),
175  ], { marginBottom: 1 })) : [T(L.none || 'Rien à signaler.', { dim: true })]
176  const alerts = box(`${L.alerts || 'Alertes'} (${vm.alerts.filter((a) => !a.acked).length})`, items, vm.julian ? 'julian' : 'dim')
177  const events = box(L.actions || 'Actions et événements', vm.recent.length ? vm.recent.map((e) => T(eventText(e), { dim: !/merged|woken|mailed|failed|missed/.test(e.kind), tone: /failed|missed/.test(e.kind) ? 'bad' : /merged/.test(e.kind) ? 'ok' : undefined })) : [T('—', { dim: true })])
178  // the last night summary (/veille jour), shown for night.show_h hours
179  const sum = h.summary ? box(null, h.summary.lines.map((l, i) => T(l, i ? { dim: true } : { bold: true })), 'info') : null
180  return col([head, sum, alerts, events])
181}
182
183const hmOf = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
184
185// The night summary to show: written by /veille jour less than night.show_h hours ago.
186export function shownSummary(night, cfg, nowMs) {
187  const show = ((cfg && cfg.night) || {}).show_h ?? 16
188  return night && Array.isArray(night.lines) && night.lines.length && nowMs - (night.generated || 0) < show * 3600000 ? night : null
189}
190
191// The Export eta tab: export-eta's batch (pending.json), why it is ready, its conversations, « Exporter vers eta »
192// (runs export-eta's exporter, the same argv as the watcher's alert), « Rafraîchir » (recomputes pending.json) and the
193// last exports (state.json). « Ouvrir Export eta » runs /export-eta for its selection and history tabs.
194function drawExport(el, cfg, xv, h) {
195  const colors = cfg.colors || {}
196  const L = xv.L
197  const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: 'wrap', ...(o.tone && colors[o.tone] ? { color: colors[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
198  const row = (parts) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: parts.filter(Boolean) })
199  const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
200  const btn = (label, onPress, primary, key) => el.Button({ key, children: [label], variant: primary ? 'primary' : 'secondary', onPress })
201  const link = (href, label) => (href && el.Link ? el.Link({ href, children: [label] }) : href ? T(`${label} ${href}`, { dim: true }) : null)
202  const batch = col([
203    T(L.title, { bold: true, tone: xv.ready ? 'warn' : undefined }),
204    xv.ready ? T(xv.text, { bold: true, tone: 'warn' }) : T(xv.none, { dim: true }),
205    xv.reason ? T(xv.reason, { dim: true }) : null,
206    ...xv.titles.slice(0, 20).map((t) => T(`· ${t}`, { dim: true })),
207    row([
208      xv.ready ? btn(h.busy === 'run' ? '…' : L.export, h.exportBatch, true, 'veille-export') : null,
209      btn(L.refresh, h.refresh, false, 'veille-export-refresh'),
210      h.alias ? null : btn(L.details, h.details, false, 'veille-export-details'),
211      xv.auto ? T(L.auto, { dim: true }) : null,
212    ]),
213    h.msg ? T(h.msg, { dim: true }) : null,
214    T(L.note, { dim: true }),
215  ], { borderStyle: 'round', borderColor: colors[xv.ready ? 'warn' : 'dim'], paddingX: 1, marginBottom: 1 })
216  const hist = col([
217    T(L.history, { bold: true }),
218    ...(xv.history.length ? xv.history.map((x) => col([
219      row([T(`● ${x.when}`, { tone: x.tone, bold: true }), T(`${x.mode} · ${x.count} conversation(s)${x.dry ? ' · essai' : ''}`), link(x.url, x.pr || 'PR'), x.url ? T(x.merged ? L.merged : L.not_merged, { tone: x.merged ? 'ok' : 'warn' }) : null]),
220      x.held.length ? T(L.held.replace('{list}', x.held.join(' ; ')), { tone: 'warn' }) : null,
221      x.error ? T(x.error, { tone: 'bad' }) : null,
222    ])) : [T(L.no_history, { dim: true })]),
223  ], { borderStyle: 'round', borderColor: colors.dim, paddingX: 1 })
224  return col([batch, hist])
225}
226
227export function register(on, raw) {
228  options = raw || {}
229
230  on('session.start', async ($, e, next) => {
231    const r = await next(e)
232    await load($).catch(() => undefined)
233    try {
234      await $.command.register({
235        name: 'veille', immediate: true,
236        description: 'Veille : un panneau, quatre onglets. Alertes (mode du veilleur, alertes, dernier tour, actions), Fil (dernier message de chaque conversation), À archiver (conversations finies), Export eta (lot de conversations terminées à exporter)',
237        argumentHint: 'alertes | fil | archiver | export | nuit | jour | mode <rien|légère|active|nuit-active|nuit-test-légère> | ack <clé|all> | coordinateur | tour | close',
238      })
239    } catch { /* a name clash must not break the rest */ }
240    // /export-eta keeps working when the export-eta mod is not loaded: an alias to the Export eta tab, registered only
241    // when the session has no /export-eta (export-eta registers its own at its session start, hence the short wait).
242    $.clock.after(3000, async () => {
243      try {
244        const names = (await $.command.list()).map((c) => c.name)
245        if (names.includes('export-eta')) return
246        await $.command.register({ name: 'export-eta', immediate: true, description: 'Export eta (alias de /veille export) : lot de conversations terminées à exporter vers eta', argumentHint: 'lot | close' })
247        D.aliasExport = true
248      } catch { /* the export-eta mod serves it */ }
249    })
250    const every = Math.max(15, ((D.cfg && D.cfg.pane) || {}).refresh_s || 60) * 1000
251    $.clock.every(every, async () => { await load($).catch(() => undefined); $.ui.invalidate('ui.render') })
252    return r
253  })
254
255  on('command.run', { command: 'veille' }, async ($, e) => {
256    await load($)
257    const q = String(e.args || '').trim()
258    const [verb, ...rest] = q.split(/\s+/)
259    const v = (verb || '').toLowerCase()
260    if (v === 'close') { D.open = false; await $.ui.close({ id: 'veille' }); return {} }
261    if (v === 'archiver' || v === 'archive') {
262      // the tab, and the list as text for the conversation that archives (archive.json; nothing is archived here)
263      await openTab($, 'archiver')
264      let now = Date.now()
265      try { now = await $.clock.now() } catch { /* Date.now */ }
266      return { text: archiveText(D.archive, now).join('\n') }
267    }
268    if (TAB_OF[v]) { await openTab($, TAB_OF[v]); return {} }
269    if (v === 'nuit' || v === 'night') { await act($, 'nuit', ['nuit']); return { text: `veille: ${D.msg}` } }
270    if (v === 'jour' || v === 'day') {
271      await act($, 'jour', ['jour'], 60000)
272      const lines = (D.night && Array.isArray(D.night.lines)) ? D.night.lines : []
273      return { text: [...lines, `veille: ${D.msg}`].join('\n') }
274    }
275    if (v === 'mode') {
276      const m = modeName(D.cfg, rest.join(' '))
277      if (!m) return { text: `veille: mode inconnu « ${rest.join(' ')} » (${Object.keys(D.cfg.modes || {}).join(', ')})` }
278      await act($, 'mode', ['mode', m])
279      return { text: `veille: ${D.msg}` }
280    }
281    if (v === 'ack') { await act($, 'ack', ['ack', rest.join(' ') || 'all']); return { text: `veille: ${D.msg}` } }
282    if (v === 'coordinateur' || v === 'coordinator') {
283      let sid = ''
284      try { sid = (await $.session.id()) || '' } catch { sid = '' }
285      if (!sid) return { text: 'veille: identifiant de session inconnu' }
286      await act($, 'coordinator', ['coordinator', sid])
287      return { text: `veille: coordinateur = cette conversation (${sid})` }
288    }
289    D.open = true
290    if (!v) D.tab = 'alertes'
291    await $.ui.open({ id: 'veille', title: 'Veille', focus: true, closeOnEscape: true })
292    if (v === 'tour') void act($, 'tour', ['tour'], 300000)
293    $.ui.invalidate('ui.render')
294    return {}
295  })
296
297  // The alias (only when veille registered it): /export-eta [lot | close] opens the Export eta tab; « lot » also runs
298  // the batch export, as export-eta's own /export-eta lot does. Otherwise export-eta's hook answers.
299  on('command.run', { command: 'export-eta' }, async ($, e, next) => {
300    if (!D.aliasExport) return next(e)
301    await load($)
302    const v = String(e.args || '').trim().toLowerCase()
303    if (v === 'close') { D.open = false; await $.ui.close({ id: 'veille' }); return {} }
304    await openTab($, 'export')
305    if (v === 'lot' || v === 'batch') void runSibling($, EXPORT_RUN)
306    return {}
307  }).catch(($, e, next) => next(e))
308
309  on('ui.close', { id: 'veille' }, async ($, e, next) => { D.open = false; return next(e) }).catch(() => undefined)
310
311  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
312    if (e.requestId !== 'veille') return next(e)
313    const el = $.ui.resolve(e)
314    if (!D.cfg) await load($).catch(() => { D.cfg = {} })
315    const cfg = D.cfg || {}
316    const vm = viewModel(cfg, D.state, D.heartbeat, D.acks, D.at || Date.now())
317    const FL = filLabels(cfg)
318    const colors = cfg.colors || {}
319    if (D.tab === 'export' && !D.pending && !D.exportState) await loadExport($).catch(() => undefined)
320    const xv = exportView(D.pending, D.exportState, cfg)
321    const AL = archiveConfig(cfg).labels || {}
322    const nArch = D.archive ? (D.archive.candidates || []).length : 0
323    const tabBtn = (id, label) => el.Button({ key: `veille-tab-${id}`, children: [label], variant: D.tab === id ? 'primary' : 'secondary', onPress: () => {
324      D.tab = id
325      const more = id === 'fil' || id === 'archiver' ? (D.filLoaded ? Promise.resolve() : loadFil($)) : id === 'export' ? loadExport($) : Promise.resolve()
326      void more.catch(() => undefined).then(() => $.ui.invalidate('ui.render'))
327    } })
328    const tabs = el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, marginBottom: 1, alignItems: 'center', children: [
329      tabBtn('alertes', FL.tab_alerts || 'Alertes'),
330      tabBtn('fil', FL.tab_fil || 'Fil'),
331      tabBtn('archiver', String(AL.tab || 'À archiver') + (nArch ? ` (${nArch})` : '')),
332      tabBtn('export', exportTabLabel(xv)),
333      vm.julian ? el.Text({ children: [String(FL.julian_counter || 'attend Julian : {n}').replace('{n}', vm.julian)], bold: true, ...(colors.julian ? { color: colors.julian } : {}) }) : null,
334    ].filter(Boolean) })
335    if (D.tab === 'archiver') {
336      if (!D.filLoaded) await loadFil($).catch(() => undefined)
337      const body = drawArchiveTab(el, cfg, D.archive, {
338        msg: D.msg, busy: D.busy,
339        copyIds: (text, n, press) => {
340          const done = String(AL.copied || '{n} id(s) copié(s)').replace('{n}', n)
341          void $.ui.copy({ text, surface: press && press.surface }).then((r) => { D.msg = r && r.isCopied === false ? text.replace(/\n/g, ' ') : done; $.ui.invalidate('ui.render') }).catch(() => { D.msg = text.replace(/\n/g, ' '); $.ui.invalidate('ui.render') })
342        },
343        refresh: () => { void (async () => { await act($, 'archiver', ['archiver'], 120000); await loadFil($); $.ui.invalidate('ui.render') })() },
344      })
345      return el.Box({ flexDirection: 'column', children: [tabs, body] })
346    }
347    if (D.tab === 'export') {
348      const body = drawExport(el, cfg, xv, {
349        busy: D.busy, msg: D.msg, alias: D.aliasExport,
350        exportBatch: () => { void runSibling($, EXPORT_RUN).then(() => loadExport($)).then(() => $.ui.invalidate('ui.render')) },
351        refresh: () => { void runSibling($, EXPORT_PENDING).then(() => loadExport($)).then(() => $.ui.invalidate('ui.render')) },
352        details: () => { void Promise.resolve($.command.run({ command: 'export-eta', args: '' })).catch(() => { D.msg = 'export-eta n’est pas chargé : « Exporter vers eta » suffit'; $.ui.invalidate('ui.render') }) },
353      })
354      return el.Box({ flexDirection: 'column', children: [tabs, body] })
355    }
356    if (D.tab === 'fil') {
357      if (!D.filLoaded) await loadFil($).catch(() => undefined)
358      const now = Date.now()
359      const body = drawFil(el, cfg, D.fil, D.filUi, {
360        now, ago, busy: D.busy, msg: D.msg, read: D.read, archive: D.archive,
361        julian: vm.julian, wakes: D.wakes,
362        filter: (f) => { D.filUi.filter = f; void saveFilUi($); $.ui.invalidate('ui.render') },
363        project: (p) => { D.filUi.project = D.filUi.project === p ? '' : p; void saveFilUi($); $.ui.invalidate('ui.render') },
364        toggleProj: (name, open) => { D.filUi.openProj = { ...D.filUi.openProj, [name]: open }; void saveFilUi($); $.ui.invalidate('ui.render') },
365        toggleConv: (sid) => {
366          const open = !D.filUi.openConv[sid]
367          D.filUi.openConv = { ...D.filUi.openConv, [sid]: open }
368          if (open) { D.read = markRead(D.read, sid, Date.now()); void $.store.set('fil.read', D.read).catch(() => undefined) }
369          $.ui.invalidate('ui.render')
370        },
371        markAll: () => { D.read = markAllRead(Date.now()); void $.store.set('fil.read', D.read).catch(() => undefined); $.ui.invalidate('ui.render') },
372        refresh: () => { void (async () => { await act($, 'fil', ['fil'], 120000); await loadFil($); $.ui.invalidate('ui.render') })() },
373        openConv: (url) => { void openUrl($, url).then((ok) => { D.msg = ok ? `${FL.opened || 'lien envoyé à l’app'} : ${url}` : `${FL.open_failed || 'ouverture impossible, copie l’id'} : ${url}`; $.ui.invalidate('ui.render') }) },
374        copy: (sid, press) => { void $.ui.copy({ text: sid, surface: press && press.surface }).then((r) => { D.msg = r && r.isCopied === false ? sid : `${FL.copied || 'id copié'} : ${sid}`; $.ui.invalidate('ui.render') }).catch(() => { D.msg = sid; $.ui.invalidate('ui.render') }) },
375      })
376      return el.Box({ flexDirection: 'column', children: [tabs, body] })
377    }
378    const alertsView = draw(el, cfg, vm, {
379      now: D.at || Date.now(), busy: D.busy, msg: D.msg,
380      mode: (m) => void act($, 'mode', ['mode', m]),
381      ack: (k) => void act($, 'ack', ['ack', k]),
382      tour: () => void act($, 'tour', ['tour'], 300000),
383      refresh: () => { void load($).then(() => $.ui.invalidate('ui.render')) },
384      run: (argv) => void runSibling($, argv),
385      nightOn: (D.cfg && D.cfg.night_started) || 0,
386      night: () => void act($, 'nuit', ['nuit']),
387      day: () => void act($, 'jour', ['jour'], 60000),
388      summary: shownSummary(D.night, cfg, D.at || Date.now()),
389    })
390    return el.Box({ flexDirection: 'column', children: [tabs, alertsView] })
391  })
392
393  // One line above the prompt (mode, last tour, needs Julian), below what the mods after this one draw (theirs kept).
394  // Only with the option band = on.
395  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
396    // off unless asked: the Jauge (band mod) already shows « attend Julian : N » once
397    if (options.band !== 'on' || (e.props && e.props.hasSurvey)) return next(e)
398    const theirs = await next(e)
399    if (!D.cfg) return theirs
400    const cfg = D.cfg
401    const parts = bandLine(viewModel(cfg, D.state, D.heartbeat, D.acks, D.at || Date.now()), cfg)
402    const el = $.ui.resolve(e)
403    const colors = cfg.colors || {}
404    const line = el.Box({ flexDirection: 'row', flexWrap: 'wrap', children: parts.map((p, i) => el.Text({
405      key: `v${i}`, children: [`${i ? ' · ' : ''}${p.text}`], ...(p.tone && p.tone !== 'dim' && colors[p.tone] ? { color: colors[p.tone] } : { dimColor: true }),
406    })) })
407    return theirs ? el.Box({ flexDirection: 'column', children: [theirs, line] }) : line
408  }).catch(($, e, next) => next(e))
409}
410
lib/modes.mjs 66 lines
1// veille - modes and watcher health: pure, no import, shared by the watcher (lib/core.mjs) and the mod's hooks.
2export const ACTIONS = ['alert', 'notify', 'notify_julian', 'mail', 'push_claude', 'merge', 'wake']
3export const MODES = ['rien', 'légère', 'active', 'nuit-active', 'nuit-test-légère']
4
5export function deepMerge(a, b) {
6  if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
7  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
8  for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(out[k], v) : v
9  return out
10}
11
12// Accept « legere », « nuit-test-legere », any case: the stored name is always the config key.
13export function modeName(cfg, x) {
14  const fold = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim()
15  return Object.keys((cfg && cfg.modes) || {}).find((k) => fold(k) === fold(x)) || null
16}
17
18export function modeOf(cfg) {
19  const name = modeName(cfg, cfg && cfg.mode) || 'rien'
20  const m = (cfg.modes || {})[name] || { interval_min: 0, may: [] }
21  return { name, ...m, may: (m.may || []).filter((a) => ACTIONS.includes(a)) }
22}
23
24// What a mode may do, with the switches that gate two of them (wake.enabled, push.claude).
25export function may(cfg, action, mode = modeOf(cfg)) {
26  if (!mode.may.includes(action)) return false
27  if (action === 'wake') return !!(cfg.wake && cfg.wake.enabled)
28  if (action === 'push_claude') return !!(cfg.push && cfg.push.claude)
29  return true
30}
31
32// "23:00-07:30" -> is `date` (local time) inside? A range may wrap past midnight.
33export function inQuietHours(spec, date) {
34  const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec || '').trim())
35  if (!m) return false
36  const a = +m[1] * 60 + +m[2], b = +m[3] * 60 + +m[4]
37  const t = date.getHours() * 60 + date.getMinutes()
38  return a <= b ? t >= a && t < b : t >= a || t < b
39}
40
41// Is a tour due at this fire? (rien: never; else when interval_min has passed since the last tour.)
42export function tourDue(mode, lastTourMs, nowMs) {
43  if (!mode.interval_min) return false
44  return !lastTourMs || nowMs - lastTourMs >= mode.interval_min * 60000 - 30000
45}
46
47// The task fires every fire_every_min. A gap of more than two fires means the watcher was not running.
48export function gapOf(lastFireMs, nowMs, fireEveryMin) {
49  if (!lastFireMs) return null
50  const limit = 2 * fireEveryMin * 60000 + 60000
51  return nowMs - lastFireMs > limit ? { from: lastFireMs, to: nowMs } : null
52}
53
54// For the pane and the band: is the watcher stale (two fires missed)?
55// cfg.installed is written by `veille install` (user config.json); heartbeat.json by every fire of the task.
56export function watcherState(heartbeat, cfg, nowMs) {
57  if (!cfg || !cfg.installed) return { state: 'not_installed', last: heartbeat && heartbeat.last_fire, last_tour: (heartbeat && heartbeat.last_tour) || 0 }
58  const last = (heartbeat && heartbeat.last_fire) || 0
59  const limit = 2 * (cfg.fire_every_min || 5) * 60000 + 60000
60  return { state: nowMs - last > limit ? 'stopped' : 'ok', last, last_tour: (heartbeat && heartbeat.last_tour) || 0 }
61}
62
63export function fill(tpl, vars) {
64  return String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
65}
66
lib/fil.mjs 365 lines
1// veille - the « Fil »: every conversation's last message in one place, grouped by project, lead → sub-conversations.
2// Pure (no I/O, no import): shared by the watcher (watcher/fil.mjs builds ~/.claude/veille/fil.json) and the pane.
3// Zero model tokens: everything comes from files the desktop app and Claude Code already write.
4// Tested with node --test mods/veille/tests/fil.test.mjs.
5
6export const FIL_DEFAULTS = {
7  hours: 48, // conversations with activity in the last N hours
8  projects: [], // [] = every project; else names (case-insensitive) of the projects to show
9  per_project: 15, // at most N conversations per project (the most recent), the rest counted as « + N autres »
10  preview_chars: 240, // collapsed preview: first two lines of the last message, cut at N characters
11  full_chars: 6000, // expanded: the full last message, cut at N characters
12  archived: 'aujourdhui', // non | aujourdhui (archived ones active today) | oui
13  live_s: 180, // transcript written in the last N seconds: « en cours »
14  approval_min: 12, // a tool call with no result for N minutes while the transcript is silent: probable approval card
15  tail_kb: 512, // bytes read from the end of a transcript, then up to max_tail_kb when no message was found
16  max_tail_kb: 4096,
17  link: 'claude://claude.ai/epitaxy/{id}',
18}
19
20export const STATES = ['en_cours', 'attend', 'bloquee', 'terminee', 'repos']
21
22export function filConfig(cfg) {
23  const f = (cfg && cfg.fil) || {}
24  return { ...FIL_DEFAULTS, ...f, labels: { ...((f && f.labels) || {}) } }
25}
26
27const tsMs = (v) => {
28  if (!v) return 0
29  if (typeof v === 'number') return v > 1e12 ? v : v * 1000
30  const t = Date.parse(v)
31  return Number.isFinite(t) ? t : 0
32}
33
34// Small stable hash (FNV-1a), so a new question is a new alert and the same question is not.
35export function hash(s) {
36  let h = 0x811c9dc5
37  for (const ch of String(s || '')) { h ^= ch.codePointAt(0); h = Math.imul(h, 0x01000193) >>> 0 }
38  return h.toString(36)
39}
40
41// ---- secrets: nothing token-like leaves a preview ---------------------------------------------------------------------
42
43const SECRET_RE = [
44  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
45  /\bgithub_pat_[A-Za-z0-9_]{20,}/g, /\bgh[pousr]_[A-Za-z0-9]{20,}/g, /\bglpat-[A-Za-z0-9_-]{16,}/g,
46  /\bsk-ant-[A-Za-z0-9_-]{16,}/g, /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
47  /\bAKIA[0-9A-Z]{16}\b/g, /\bAIza[0-9A-Za-z_-]{30,}/g, /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
48]
49const KV_RE = /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|client[_-]?secret|passw(?:or)?d|pwd)\s*[:=]\s*|Bearer\s+)(["']?)([^\s"'`,;]{6,})/gi
50const LONG_RE = /[A-Za-z0-9+/_-]{40,}={0,2}/g
51
52export function mask(text) {
53  let t = String(text ?? '')
54  for (const re of SECRET_RE) t = t.replace(re, '•••')
55  t = t.replace(KV_RE, (m, k, q) => `${k}${q}•••`)
56  // a long run mixing upper case, lower case and digits looks like a key (hex SHAs and ids, lower case only, stay)
57  t = t.replace(LONG_RE, (m) => (/[A-Z]/.test(m) && /[a-z]/.test(m) && /\d/.test(m) ? '•••' : m))
58  return t
59}
60
61// ---- the last message of a transcript ----------------------------------------------------------------------------------
62
63const str = (v) => (typeof v === 'string' ? v.trim() : '')
64
65function actionsOf(a) {
66  let x = a
67  if (typeof x === 'string') { try { x = JSON.parse(x) } catch { return [] } }
68  return Array.isArray(x) ? x.map((b) => str(b && (b.label || b.message))).filter(Boolean) : []
69}
70
71const NO_RESULT_TOOLS = /(^|__)wrap_up$|^(Agent|Task|Workflow|AskUserQuestion|TaskOutput|Monitor)$/
72
73// A new turn starts with a user record that carries text (a prompt, a task notification), not only tool results.
74function isTurnStart(d) {
75  if (d.type !== 'user' || d.isMeta || d.isSidechain) return false
76  const c = d.message && d.message.content
77  if (typeof c === 'string') return !!c.trim()
78  return Array.isArray(c) && c.some((b) => b && b.type === 'text' && str(b.text)) && !c.some((b) => b && b.type === 'tool_result')
79}
80
81// records: parsed transcript lines, oldest first (a tail is enough). Tool calls, results and thinking are left out.
82export function lastMessage(records) {
83  let text = '', textAt = 0, textId = null, textIdx = -1, parts = []
84  let wrap = null, turnIdx = -1, ask = null, lastAt = 0
85  const pending = new Map()
86  let i = 0
87  for (const d of records || []) {
88    i++
89    if (!d || typeof d !== 'object') continue
90    const ts = tsMs(d.timestamp)
91    if (d.type === 'user' && !d.isSidechain) {
92      const c = d.message && d.message.content
93      if (Array.isArray(c)) for (const b of c) if (b && b.type === 'tool_result') { pending.delete(b.tool_use_id); if (ask && ask.id === b.tool_use_id) ask = null }
94      if (isTurnStart(d)) { turnIdx = i; ask = null; pending.clear() }
95      continue
96    }
97    if (d.type !== 'assistant' || d.isSidechain) continue
98    const m = d.message || {}
99    if (m.model === '<synthetic>') continue
100    if (ts) lastAt = ts
101    for (const b of Array.isArray(m.content) ? m.content : []) {
102      if (!b) continue
103      if (b.type === 'text' && str(b.text)) {
104        if (m.id && m.id === textId && textIdx > turnIdx) parts.push(b.text)
105        else { parts = [b.text]; textId = m.id || `i${i}` }
106        text = parts.join('\n\n'); textAt = ts; textIdx = i
107      } else if (b.type === 'tool_use') {
108        const inp = b.input || {}
109        if (/(^|__)wrap_up$/.test(b.name || '')) wrap = { recap: str(inp.recap), ask: str(inp.ask), actions: actionsOf(inp.actions), blocked: !!inp.blocked, at: ts, idx: i }
110        else if (b.name === 'AskUserQuestion') {
111          const qs = Array.isArray(inp.questions) ? inp.questions : []
112          ask = { id: b.id, q: str(qs[0] && (qs[0].question || qs[0].header)) || str(inp.question), at: ts }
113        }
114        if (!NO_RESULT_TOOLS.test(b.name || '') && !inp.run_in_background) pending.set(b.id, { name: b.name, at: ts })
115      }
116    }
117  }
118  const current = !!wrap && wrap.idx > turnIdx
119  const pend = [...pending.values()]
120  return {
121    text, text_at: textAt, text_current: textIdx > turnIdx, last_at: Math.max(lastAt, textAt),
122    wrap: wrap ? { recap: wrap.recap, ask: wrap.ask, actions: wrap.actions, blocked: wrap.blocked, at: wrap.at, current } : null,
123    ask_open: ask ? { q: ask.q, at: ask.at } : null,
124    pending_tool: pend.length ? pend[pend.length - 1] : null,
125  }
126}
127
128// ---- « attend Julian »: the four signals (heuristics documented in the README) -------------------------------------------
129
130// A message that ends on a question to Julian: its last paragraph ends with « ? », or opens with a request.
131const ASK_OPENERS = /^(?:\*\*)?(tell me|let me know|should i|shall i|do you want|want me to|would you like|can you|could you|please (?:confirm|choose|decide|pick|approve)|which (?:one|option)|dois-je|veux-tu|voulez-vous|souhaites-tu|préfères-tu|tu préfères|dis-moi|confirme|on (?:merge|lance|garde)|je (?:merge|lance) \?)/i
132
133export function endsWithQuestion(text) {
134  // a closing « Vérifié à 21:42 » status line (Julian's rule) is not the end of the message
135  const t = String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/(?:\n\s*)*\**(?:vérifié|verified|checked)\s+(?:à|at)\s+\d{1,2}[:h]\d{2}\.?\**\s*$/i, '').trim()
136  if (!t) return ''
137  const paras = t.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean)
138  const last = (paras[paras.length - 1] || '').replace(/[*_`>#\s]+$/g, '').trim()
139  const plain = last.replace(/^[-*>#\s]+/, '').replace(/\*\*/g, '')
140  const sentence = (plain.split(/(?<=[.!?])\s+/).filter(Boolean).pop() || plain).trim()
141  if (/\?\s*\)?$/.test(plain)) return sentence.slice(0, 300)
142  if (ASK_OPENERS.test(sentence) || ASK_OPENERS.test(plain)) return sentence.slice(0, 300)
143  return ''
144}
145
146// A session that reports contradicting instructions (brief or packet vs rules vs Julian's own message): high priority.
147const INCOHERENCE_RE = /\b(contradicts?|contradiction|contradictoire|conflicts? between|conflicting instructions|said no merge|says no merge|brief says|packet says|incohéren\w*|consignes? contraires?|consignes? (?:se )?contredi\w*)/i
148
149export function incoherenceOf(text) {
150  const t = String(text || '').replace(/```[\s\S]*?```/g, ' ')
151  const m = INCOHERENCE_RE.exec(t)
152  if (!m) return ''
153  const start = Math.max(t.lastIndexOf('\n', m.index), t.lastIndexOf('. ', m.index) + 1) + 1
154  const end = t.slice(m.index).search(/[.?!](?:\s|$)|\n/)
155  return t.slice(start, end < 0 ? undefined : m.index + end + 1).replace(/[*_`#>]/g, '').trim().slice(0, 300)
156}
157
158// Which signal says the conversation waits on Julian, if any: {kind, question}. Order: pending AskUserQuestion,
159// wrap_up ask or blocked, the app's needs_action / interrupted-awaiting, a closing question, a probable approval card.
160export function julianSignal(s, msg, nowMs, f = FIL_DEFAULTS) {
161  const m = msg || {}
162  const live0 = s.mtime && nowMs - s.mtime < (f.live_s || 180) * 1000
163  const wc = m.wrap && m.wrap.current ? m.wrap : null
164  const inco = (wc && (incoherenceOf(wc.ask) || incoherenceOf(wc.recap))) || (!live0 && m.text_current && incoherenceOf(m.text))
165  if (inco) return { kind: 'incoherence', question: `Incohérence entre consignes : ${inco}` }
166  if (m.ask_open) return { kind: 'pending_ask', question: m.ask_open.q || 'Question en attente (AskUserQuestion)' }
167  if (m.wrap && m.wrap.current && (m.wrap.ask || m.wrap.blocked)) return { kind: 'wrap', question: m.wrap.ask || m.wrap.recap || 'Bloquée (wrap_up)' }
168  if (str(s.needs_action)) return { kind: 'needs', question: str(s.needs_action) }
169  if (s.awaiting) return { kind: 'awaiting', question: 'Interrompue, attend une personne' }
170  const live = s.mtime && nowMs - s.mtime < (f.live_s || 180) * 1000
171  if (!live && m.text_current) {
172    const q = endsWithQuestion(m.text)
173    if (q) return { kind: 'question', question: q }
174  }
175  if (!live && m.pending_tool && m.pending_tool.at && nowMs - Math.max(m.pending_tool.at, s.mtime || 0) >= (f.approval_min || 12) * 60000) {
176    return { kind: 'approval', question: `Approbation probable : ${m.pending_tool.name} sans résultat (non vérifié)` }
177  }
178  return null
179}
180
181export function stateOf(s, msg, nowMs, f = FIL_DEFAULTS) {
182  const sig = julianSignal(s, msg, nowMs, f)
183  if (sig && ['pending_ask', 'wrap', 'incoherence'].includes(sig.kind)) return 'attend' // the turn ended on Julian, even seconds ago
184  if (s.mtime && nowMs - s.mtime < (f.live_s || 180) * 1000) return 'en_cours'
185  if (sig) return 'attend'
186  const w = msg && msg.wrap
187  if (s.status_category === 'blocked') return 'bloquee'
188  if (s.status_category === 'completed') return 'terminee'
189  if (w && w.current && w.blocked) return 'bloquee'
190  return 'repos'
191}
192
193// ---- text helpers ------------------------------------------------------------------------------------------------------
194
195const PR_RE = /https?:\/\/(?:github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+|[\w.-]*gitlab[\w.-]*\/[\w./-]+?\/-\/merge_requests\/\d+)/g
196
197export function prLinks(text, appPrs = []) {
198  const out = []
199  const add = (url, state) => { const u = String(url || '').replace(/[).,;]+$/, ''); if (u && !out.some((x) => x.url.toLowerCase() === u.toLowerCase())) out.push({ url: u, state: state || '' }) }
200  for (const p of appPrs || []) if (p && p.url) add(p.url, String(p.state || '').toLowerCase())
201  for (const m of String(text || '').match(PR_RE) || []) add(m)
202  return out
203}
204
205export function plainLines(md) {
206  return String(md || '')
207    .replace(/```[\s\S]*?```/g, ' ')
208    .split(/\r?\n/)
209    .map((l) => l.replace(/^\s*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s*)/, '').replace(/\*\*|__|`/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^\|+|\|+$/g, '').replace(/\s*\|\s*/g, ' · ').trim())
210    .filter((l) => l && !/^[-:·\s]+$/.test(l))
211}
212
213export function previewOf(source, chars = 240) {
214  const lines = plainLines(source).slice(0, 2)
215  const t = lines.join(' · ')
216  return t.length > chars ? t.slice(0, chars - 1) + '…' : t
217}
218
219export function projectOf(cwd) {
220  const p = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '')
221  if (!p) return '?'
222  const base = p.replace(/\/\.claude\/worktrees\/[^/]+.*$/, '')
223  if (/^[A-Za-z]:\/Users\/[^/]+$|^\/(home|Users)\/[^/]+$/.test(base)) return '~'
224  return base.split('/').filter(Boolean).pop() || base
225}
226
227export function hm(ms) {
228  if (!ms) return '—'
229  const d = new Date(ms)
230  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
231}
232
233const sameDay = (a, b) => { const x = new Date(a), y = new Date(b); return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate() }
234
235// ---- the digest -------------------------------------------------------------------------------------------------------
236
237// rows: one per app session {sid, cli, title, cwd, origin, model, effort, archived, last_activity (ms), mtime (ms),
238// parent (sid), status_category, needs_action, awaiting, prs:[{url,state}], msg: lastMessage(...)}.
239export function buildFil(rows, cfg, nowMs) {
240  const f = filConfig(cfg)
241  const keepArchived = (r) => !r.archived || f.archived === 'oui' || (f.archived === 'aujourdhui' && sameDay(r.last_activity, nowMs))
242  const all = (rows || []).filter((r) => r && r.sid && nowMs - (r.last_activity || 0) <= f.hours * 3600000 && keepArchived(r))
243  const bySid = new Map(all.map((r) => [r.sid, r]))
244  const projectFor = (r, seen = new Set()) => {
245    if (r.parent && bySid.has(r.parent) && !seen.has(r.parent)) { seen.add(r.sid); return projectFor(bySid.get(r.parent), seen) }
246    return projectOf(r.origin || r.cwd)
247  }
248  const wanted = (f.projects || []).map((x) => String(x).toLowerCase())
249  const convs = all.map((r) => {
250    const m = r.msg || {}
251    const sig = julianSignal(r, m, nowMs, f)
252    const state = stateOf(r, m, nowMs, f)
253    const useWrap = m.wrap && m.wrap.current && m.wrap.recap
254    const source = useWrap ? m.wrap.recap : m.text
255    const full = mask(m.text || '')
256    return {
257      sid: r.sid, cli: r.cli || '', title: mask(r.title || '(sans titre)'), project: projectFor(r), state,
258      model: r.model || '', effort: r.effort || '', archived: !!r.archived, parent: r.parent && bySid.has(r.parent) ? r.parent : null,
259      parent_title: r.parent && !bySid.has(r.parent) ? mask(r.parent_title || '') : '',
260      last_at: Math.max(m.last_at || 0, r.last_activity || 0), msg_at: m.text_at || (m.wrap && m.wrap.at) || 0,
261      link: String(f.link || '').replace('{id}', r.sid),
262      preview: mask(previewOf(source, f.preview_chars)), from_wrap: !!useWrap,
263      recap: m.wrap && m.wrap.current ? mask(m.wrap.recap) : '', ask: mask((sig && sig.question) || ''), signal: sig ? sig.kind : '',
264      actions: m.wrap && m.wrap.current ? m.wrap.actions.map(mask) : [],
265      text: full.length > f.full_chars ? full.slice(0, f.full_chars - 1) + '…' : full, truncated: full.length > f.full_chars,
266      prs: prLinks(m.text, r.prs),
267    }
268  }).filter((c) => !wanted.length || wanted.includes(c.project.toLowerCase()))
269
270  const projects = new Map()
271  for (const c of convs) {
272    if (!projects.has(c.project)) projects.set(c.project, [])
273    projects.get(c.project).push(c)
274  }
275  const out = []
276  for (const [name, list] of projects) {
277    list.sort((a, b) => b.last_at - a.last_at)
278    const kept = list.filter((c, i) => i < f.per_project || c.state === 'attend') // what waits on Julian is never cut
279    const keptSids = new Set(kept.map((c) => c.sid))
280    const counts = Object.fromEntries(STATES.map((s) => [s, list.filter((c) => c.state === s).length]))
281    const nodes = new Map(kept.map((c) => [c.sid, { ...c, children: [] }]))
282    const roots = []
283    for (const c of kept) {
284      const n = nodes.get(c.sid)
285      if (c.parent && keptSids.has(c.parent)) nodes.get(c.parent).children.push(n)
286      else roots.push(n)
287    }
288    const latest = (n) => Math.max(n.last_at, ...n.children.map(latest))
289    const sortTree = (ns) => { ns.sort((a, b) => latest(b) - latest(a)); for (const n of ns) sortTree(n.children) }
290    sortTree(roots)
291    out.push({ name, last: list[0].last_at, total: list.length, hidden: list.length - kept.length, counts, convs: roots })
292  }
293  out.sort((a, b) => b.last - a.last)
294  const julian = convs.filter((c) => c.state === 'attend').length
295  return { v: 1, generated: nowMs, hours: f.hours, total: convs.length, julian, projects: out }
296}
297
298// « 5 conv · 2 en cours · 1 attend Julian · dernière activité 20:41 »
299export function summaryLine(p, labels = {}) {
300  const L = { conv: 'conv', en_cours: 'en cours', attend: 'attend Julian', bloquee: 'bloquée(s)', last: 'dernière activité', ...labels }
301  const parts = [`${p.total} ${L.conv}`]
302  for (const s of ['en_cours', 'attend', 'bloquee']) if (p.counts[s]) parts.push(`${p.counts[s]} ${L[s]}`)
303  parts.push(`${L.last} ${hm(p.last)}`)
304  return parts.join(' · ')
305}
306
307// ---- what the pane needs: new since last read, filters, the alerts the watcher raises -----------------------------------
308
309// read = {since, by: {sid: ms}} kept in the mod's $.store. A conversation is new when its last message came after both.
310export function isNew(c, read) {
311  if (!read || !read.since) return false
312  const at = c.msg_at || c.last_at || 0
313  return at > Math.max(read.since, (read.by || {})[c.sid] || 0)
314}
315
316export function markRead(read, sid, nowMs) {
317  const by = { ...((read && read.by) || {}) }
318  by[sid] = nowMs
319  for (const [k, t] of Object.entries(by)) if (nowMs - t > 14 * 86400000) delete by[k]
320  return { since: (read && read.since) || nowMs, by }
321}
322
323export const markAllRead = (nowMs) => ({ since: nowMs, by: {} })
324
325export const FILTERS = ['tout', 'attend', 'bloquee', 'terminee_auj', 'projet']
326
327function matches(c, filter, nowMs) {
328  if (filter === 'attend') return c.state === 'attend'
329  if (filter === 'bloquee') return c.state === 'bloquee'
330  if (filter === 'terminee_auj') return c.state === 'terminee' && sameDay(c.last_at, nowMs)
331  return true
332}
333
334// Keeps the tree: a lead stays when it or one of its sub-conversations matches (then `dim` when only a child does).
335export function filterFil(fil, filter = 'tout', project = '', nowMs = Date.now()) {
336  const prune = (ns) => ns.map((n) => {
337    const kids = prune(n.children || [])
338    const ok = matches(n, filter, nowMs)
339    return ok || kids.length ? { ...n, children: kids, dim: !ok } : null
340  }).filter(Boolean)
341  const projects = ((fil && fil.projects) || [])
342    .filter((p) => filter !== 'projet' || !project || p.name === project)
343    .map((p) => ({ ...p, convs: filter === 'tout' || filter === 'projet' ? p.convs : prune(p.convs) }))
344    .filter((p) => p.convs.length)
345  return { ...(fil || {}), projects }
346}
347
348export function flatten(ns, depth = 0, out = []) {
349  for (const n of ns || []) { out.push({ ...n, depth }); flatten(n.children, depth + 1, out) }
350  return out
351}
352
353// The « attend Julian » alerts the watcher raises from the Fil: one per conversation and per question.
354export function julianAlerts(fil) {
355  const out = []
356  for (const p of (fil && fil.projects) || []) {
357    for (const c of flatten(p.convs)) {
358      if (c.state !== 'attend' || !c.signal) continue
359      out.push({ key: `fil-${c.signal}:${c.cli || c.sid}:${hash(c.ask)}`, kind: `fil_${c.signal}`, what: `Attend Julian : ${c.ask}`,
360        title: c.title, cli: c.cli, sid: c.sid, session_link: c.link, link: c.link, since: c.msg_at || c.last_at, julian: true })
361    }
362  }
363  return out
364}
365
lib/archive.mjs 101 lines
1// veille - « À archiver »: which finished conversations the VEILLE conversation may archive in one batch.
2// Pure (no I/O): the watcher (watcher/archive.mjs) feeds it every open session of the desktop app and writes
3// ~/.claude/veille/archive.json; the pane and `/veille archiver` read that file. Nothing here archives anything (a script
4// cannot: archiving is the VEILLE conversation's archive_session call). Tested with node --test mods/veille/tests/fil.test.mjs.
5import { julianSignal, filConfig, mask, projectOf } from './fil.mjs'
6
7export const ARCHIVE_DEFAULTS = {
8  idle_h: 6, // a candidate has had no activity (session file, transcript, sub-agent files) for at least N hours
9  // role conversations are never candidates: an upper-case name then a number (« VEILLE 1 », « MODS 1 », « SUPERVISOR 2 »)
10  role_pattern: "^[A-ZÀ-Ý][A-ZÀ-Ý0-9 &_'.-]*?\\s\\d+(?:\\s|$|[(:·-])",  keep_status: ['blocked'], // the app's postTurnSummary.status_category values that keep a conversation (blocked = waits)
11  keep_newest_per_project: true, // the newest open conversation of each project stays, so every project stays in the sidebar
12  max_list: 200,
13  labels: {
14    title: 'À archiver ({n})', copy_ids: 'Copier la liste des ids', copied: '{n} id(s) copié(s)',
15    note: 'La conversation VEILLE les archive en un lot (archive_session) ; le script n’archive jamais.',
16    none: 'Rien à archiver.', missing: 'archive.json absent : la tâche ClaudeVeille l’écrit au prochain passage.',
17  },
18}
19
20export function archiveConfig(cfg) {
21  const a = (cfg && cfg.archive) || {}
22  return { ...ARCHIVE_DEFAULTS, ...a, labels: { ...ARCHIVE_DEFAULTS.labels, ...((a && a.labels) || {}) } }
23}
24
25const H = 3600000
26const hours = (ms) => Math.max(0, Math.floor(ms / H))
27export const ageText = (ms) => (ms >= 48 * H ? `${Math.floor(ms / (24 * H))} j` : `${hours(ms)} h`)
28
29function roleRe(p) {
30  try { return p ? new RegExp(p) : null } catch { return null }
31}
32
33const prNum = (p) => p.prNumber || (String(p.url || '').match(/(?:pull|merge_requests)\/(\d+)/) || [])[1] || '?'
34const OPEN = /^(open|opened|draft)$/i
35
36// rows: one per desktop-app session {sid, cli, title, cwd, origin, archived, pinned, exempt, last_activity (ms),
37// activity (ms: the latest of the session file, its transcript and its sub-agent files), status_category, needs_action,
38// awaiting, prs:[{url,state,prNumber}], msg: lastMessage(...) or null, mtime (transcript ms)}.
39// Returns {candidates, kept: {reason: n}, open}. Every rule that keeps a conversation wins over every rule that lists it.
40export function archiveCandidates(rows, cfg, nowMs) {
41  const A = archiveConfig(cfg)
42  const f = filConfig(cfg)
43  const role = roleRe(A.role_pattern)
44  const open = (rows || []).filter((r) => r && r.sid && !r.archived)
45  const at = (r) => Math.max(r.activity || 0, r.last_activity || 0, r.mtime || 0)
46  // the newest open conversation per project (the sidebar groups by the folder the conversation was started in)
47  const newest = new Map()
48  for (const r of open) {
49    const p = projectOf(r.origin || r.cwd)
50    if (!newest.has(p) || at(r) > at(newest.get(p))) newest.set(p, r)
51  }
52  const kept = { pinned: 0, role: 0, newest: 0, active: 0, julian: 0, status: 0, open_pr: 0, running: 0 }
53  const out = []
54  for (const r of open) {
55    const project = projectOf(r.origin || r.cwd)
56    const idle = nowMs - at(r)
57    const prs = (r.prs || []).filter((p) => p && p.url)
58    const m = r.msg || {}
59    let why = ''
60    if (r.pinned || r.exempt) why = 'pinned'
61    else if (role && role.test(String(r.title || '').trim())) why = 'role'
62    else if (A.keep_newest_per_project && newest.get(project) === r) why = 'newest'
63    else if (idle < A.idle_h * H) why = 'active'
64    else if (julianSignal(r, m, nowMs, f) || r.awaiting || String(r.needs_action || '').trim()) why = 'julian'
65    else if ((A.keep_status || []).includes(r.status_category)) why = 'status'
66    else if (prs.some((p) => OPEN.test(String(p.state || '')))) why = 'open_pr'
67    else if (m.pending_tool) why = 'running' // a tool call with no result: the turn did not end
68    if (why) { kept[why]++; continue }
69    const last = prs[prs.length - 1]
70    const pr = last ? `PR #${prNum(last)} ${/^merged$/i.test(last.state) ? 'mergée' : /^closed$/i.test(last.state) ? 'fermée' : String(last.state || '').toLowerCase()}` : 'pas de PR'
71    const done = r.status_category === 'completed' ? 'terminée' : r.status_category ? r.status_category : 'tour fini'
72    out.push({
73      sid: r.sid, cli: r.cli || '', title: mask(r.title || '(sans titre)'), project, last_at: at(r), idle_h: hours(idle),
74      reason: `${pr}, ${done}, inactive ${ageText(idle)}`, pr: last ? { url: last.url, state: String(last.state || '').toLowerCase() } : null,
75    })
76  }
77  out.sort((a, b) => a.last_at - b.last_at) // the oldest first
78  return { candidates: out.slice(0, A.max_list), kept, open: open.length }
79}
80
81export function buildArchive(rows, cfg, nowMs) {
82  const A = archiveConfig(cfg)
83  const r = archiveCandidates(rows, cfg, nowMs)
84  return { v: 1, generated: nowMs, idle_h: A.idle_h, open: r.open, count: r.candidates.length, kept: r.kept, candidates: r.candidates }
85}
86
87// The ids, one per line (what « Copier la liste des ids » copies and what the VEILLE conversation passes to archive_session).
88export const archiveIds = (arch) => ((arch && arch.candidates) || []).map((c) => c.sid).join('\n')
89
90// `/veille archiver` and the terminal render: a header, then one line per candidate.
91export function archiveText(arch, nowMs = Date.now()) {
92  if (!arch) return ['archive.json absent : la veille ne l’a pas encore écrit (node veille.mjs archiver)']
93  const c = arch.candidates || []
94  const head = `À archiver : ${c.length} sur ${arch.open} conversation(s) ouvertes (inactives ≥ ${arch.idle_h} h), calculé il y a ${ageText(Math.max(0, nowMs - (arch.generated || nowMs)))}`
95  const lines = [head]
96  for (const x of c) lines.push(`- ${x.title} · ${x.project} · ${x.reason} · ${x.sid}`)
97  if (!c.length) lines.push('Rien à archiver.')
98  else lines.push('Archivage : la conversation VEILLE appelle archive_session pour chaque id, en un lot ; le script n’archive jamais.')
99  return lines
100}
101
lib/export-view.mjs 70 lines
1// veille - the « Export eta » tab: export-eta's batch export seen from the Veille pane. Pure (no $, no I/O), tested
2// with node --test. Inputs: ~/.claude/export-eta/pending.json and state.json, written by export-eta's exporter
3// (exporter/export.mjs); the tab only reads them. Its buttons run that same exporter (EXPORT_RUN, the argv the
4// watcher's alert carries too), so there is one code path and nothing is pushed beyond what export-eta itself does.
5
6// The exporter, relative to the folder holding the mods (or the watcher's bin copy).
7export const EXPORT_SCRIPT = 'export-eta/exporter/export.mjs'
8// What « Exporter vers eta » runs: the batch export, the same argv as the watcher's alert button.
9export const EXPORT_RUN = [EXPORT_SCRIPT, 'run', '--mode', 'batch']
10// What « Rafraîchir » runs: recompute pending.json (no export, no git).
11export const EXPORT_PENDING = [EXPORT_SCRIPT, 'pending']
12
13const fill = (t, v = {}) => String(t ?? '').replace(/\{(\w+)\}/g, (m, k) => (v[k] === undefined || v[k] === null ? m : String(v[k])))
14
15export const EXPORT_LABELS = {
16  tab: 'Export eta',
17  title: 'Export eta · lot terminé',
18  ready_reason_lead: 'un lead et toutes ses sous-conversations sont terminés',
19  ready_reason_count: 'seuil {min} atteint',
20  none: 'Aucun lot prêt ({n} terminée(s) depuis le dernier export, seuil {min}).',
21  missing: 'pending.json absent : export-eta n’a pas encore tourné (« Rafraîchir » le calcule).',
22  export: 'Exporter vers eta',
23  refresh: 'Rafraîchir',
24  details: 'Ouvrir Export eta',
25  auto: 'auto : la veille l’exporte sans clic',
26  history: 'Derniers exports',
27  no_history: 'aucun export',
28  merged: 'mergée',
29  not_merged: 'pas mergée',
30  held: 'retenues : {list}',
31  note: 'Le bouton lance l’exporteur d’export-eta (masquage, worktree neuf du dépôt eta, PR, merge squash) ; la Veille ne pousse rien d’autre.',
32}
33
34function when(ms) {
35  if (!ms) return ''
36  const d = new Date(ms)
37  const p = (x) => String(x).padStart(2, '0')
38  return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`
39}
40
41/**
42 * The tab's view model. pending/state: export-eta's files (null when absent). cfg: veille's merged config
43 * (cfg.export.labels overrides EXPORT_LABELS, cfg.export.batch_min the threshold shown when pending.json has none).
44 */
45export function exportView(pending, state, cfg = {}) {
46  const X = (cfg && cfg.export) || {}
47  const L = { ...EXPORT_LABELS, ...(X.labels || {}) }
48  const min = (pending && pending.min) ?? X.batch_min ?? 3
49  const history = ((state && state.history) || []).slice(0, X.history || 3).map((h) => ({
50    when: when(h.at), mode: h.mode || '', count: h.count || 0, url: h.url || '', pr: h.pr ? `#${h.pr}` : '', merged: !!h.merged,
51    held: (h.held || []).map((x) => x.title || x.cli).filter(Boolean), error: h.error || '', dry: !!h.dry,
52    tone: h.error && !h.merged ? 'bad' : h.held && h.held.length ? 'warn' : 'ok',
53  }))
54  if (!pending) return { L, available: false, ready: false, count: 0, text: '', reason: '', titles: [], none: L.missing, auto: false, history }
55  const ready = !!pending.ready && (pending.ids || []).length > 0
56  return {
57    L, available: true, ready, count: pending.count || 0, auto: !!pending.auto,
58    text: ready ? pending.text || `${pending.count} conversations terminées — ${L.export}` : '',
59    reason: !ready ? '' : pending.reason === 'lead' ? L.ready_reason_lead : pending.reason === 'count' ? fill(L.ready_reason_count, { min }) : '',
60    titles: ready ? (pending.titles || []).map((t) => `${t.project || '?'} · ${t.title || String(t.cli || '').slice(0, 8)}`) : [],
61    none: fill(L.none, { n: pending.count || 0, min }),
62    history,
63  }
64}
65
66/** The tab's label: « Export eta (4) » while a batch is ready. */
67export function exportTabLabel(vm) {
68  return vm && vm.ready ? `${vm.L.tab} (${vm.count})` : (vm && vm.L.tab) || EXPORT_LABELS.tab
69}
70
hooks/view.mjs 83 lines
1// veille - view model of the pane and the band line: pure (no $, no I/O), tested with node --test.
2// Input: the merged config, ~/.claude/veille/state.json, heartbeat.json and the ack keys. The watcher writes those;
3// the mod only reads them and runs `node watcher/veille.mjs mode|ack|tour|coordinator`.
4import { modeOf, watcherState, fill } from '../lib/modes.mjs'
5
6// « attend Julian : N »: the one count is lib/core/veille.mjs attendJulian (open alerts marked julian, not acked, '*'
7// acks all). A hooks module may not import it (it imports node:fs for readVeille), so this is the same rule in one
8// expression; tests/hub.test.mjs checks it gives attendJulian's number on every fixture.
9export const julianCount = (state, acks = []) => {
10  const ack = new Set(acks)
11  return Object.values((state && state.alerts) || {}).filter((a) => a && a.julian && !(a.acked || ack.has(a.key) || ack.has('*'))).length
12}
13
14export function ago(ms) {
15  const m = Math.max(0, Math.round(ms / 60000))
16  if (m < 60) return `${m} min`
17  const h = Math.floor(m / 60)
18  return h < 48 ? `${h} h ${String(m % 60).padStart(2, '0')}` : `${Math.floor(h / 24)} j`
19}
20
21export function hm(ms) {
22  if (!ms) return ''
23  const d = new Date(ms)
24  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25}
26
27const RANK = { julian: 0, bad: 1, warn: 2, info: 3 }
28
29export function viewModel(cfg, state, heartbeat, acks, nowMs) {
30  const L = cfg.labels || {}
31  const mode = modeOf(cfg)
32  const w = watcherState(heartbeat, cfg, nowMs)
33  const ackSet = new Set(acks || [])
34  const alerts = Object.values((state && state.alerts) || {})
35    .map((a) => ({ ...a, acked: a.acked || ackSet.has(a.key) || ackSet.has('*') }))
36    .sort((a, b) => (a.acked - b.acked) || ((RANK[a.severity] ?? 9) - (RANK[b.severity] ?? 9)) || ((a.since || 0) - (b.since || 0)))
37  const julian = julianCount(state, acks || [])
38  let watcher
39  if (mode.name === 'rien') watcher = { tone: 'dim', text: `${L.last_tour || 'Dernier tour'} : ${w.last_tour ? hm(w.last_tour) : L.never || 'jamais'} (veille éteinte)` }
40  else if (w.state === 'not_installed') watcher = { tone: 'bad', text: L.not_installed || 'tâche non installée' }
41  else if (w.state === 'stopped') watcher = { tone: 'bad', text: fill(L.stopped, { since: w.last ? `${hm(w.last)} (${ago(nowMs - w.last)})` : L.never || 'jamais' }) }
42  else watcher = { tone: 'ok', text: `${L.last_tour || 'Dernier tour'} : ${w.last_tour ? `${hm(w.last_tour)} (il y a ${ago(nowMs - w.last_tour)})` : L.never || 'jamais'} · tâche vue à ${hm(w.last)}` }
43  const may = mode.may.map((a) => (L.may_names || {})[a] || a)
44  if (mode.may.includes('wake') && !(cfg.wake || {}).enabled) may.push(`(${L.wake_off || 'réveils désactivés'})`)
45  return {
46    mode, modes: Object.entries(cfg.modes || {}).map(([id, m]) => ({ id, label: m.label || id, current: id === mode.name })),
47    may, watcher, alerts, julian,
48    mail: state && state.mail === 'not_configured' ? L.mail_off || 'e-mail non configuré' : state && state.mail === 'error' ? 'e-mail en erreur (voir les événements)' : state && state.mail ? L.mail_on || 'e-mail configuré' : '',
49    coordinator: cfg.coordinator_session || '',
50    error: (state && state.last_error) || (heartbeat && heartbeat.last_error) || '',
51    recent: ((state && state.recent) || []).slice(0, (cfg.pane || {}).events || 25),
52    reviewsErrors: (state && state.reviews_errors) || [],
53  }
54}
55
56// One line above the prompt: mode, last tour, how many wait on Julian. Tone bad when the watcher stopped.
57export function bandLine(vm, cfg) {
58  const L = cfg.labels || {}
59  // the red « attend Julian : N » counter comes first, so it is never cut off the end of the line
60  const parts = vm.julian ? [{ text: String(L.julian_counter || 'attend Julian : {n}').replace('{n}', vm.julian), tone: 'julian' }] : []
61  parts.push({ text: `${L.band || 'Veille'} : ${vm.mode.label || vm.mode.name}`, tone: vm.mode.name === 'rien' ? 'dim' : undefined })
62  if (vm.mode.name !== 'rien') {
63    if (vm.watcher.tone === 'bad') parts.push({ text: vm.watcher.text, tone: 'bad' })
64    else if (vm.watcher.tone === 'ok') parts.push({ text: vm.watcher.text.split(' · ')[0].replace(/^[^:]+: /, 'tour '), tone: 'dim' })
65  }
66  return parts
67}
68
69// What an event of state.recent says, in one line.
70export function eventText(e) {
71  const t = hm(e.at)
72  const what = {
73    merged: `mergée : ${e.pr || e.url} (${e.reason || ''})`, merge_failed: `merge échoué : ${e.pr || e.url} : ${e.error || ''}`,
74    merge_skipped: `pas mergée : ${e.pr || e.url} : ${e.reason || ''}`, alert: `alerte : ${e.what || e.key}${e.title ? ' · ' + e.title : ''}`,
75    resolved: `réglée : ${e.what || e.key}`, notified: `notifiée : ${e.key}`, mailed: `e-mail envoyé (${(e.keys || []).length} ligne(s))`,
76    mail_skipped: `e-mail non envoyé : ${e.reason || ''}`, mail_failed: `e-mail en erreur : ${e.error || ''}`,
77    woken: `réveil de ${e.target || e.cli} : ${e.reason || ''}`, wake_skipped: `pas de réveil (${e.reason || ''}) : ${e.key}`,
78    push: `push Claude demandé : ${e.reason || ''}`, push_skipped: `push non envoyé : ${e.reason || ''}`,
79    tour_failed: `tour échoué : ${e.error || ''}`, exported: `export eta : ${e.reason || ''}`, export_failed: `export eta échoué : ${e.error || ''}`, missed: `veille arrêtée de ${hm(e.from)} à ${hm(e.to)}`,
80  }[e.kind] || `${e.kind}${e.reason ? ' : ' + e.reason : ''}`
81  return `${t} ${what}`
82}
83
hooks/fil-view.mjs 193 lines
1// veille - the « Fil » screen of the Veille pane: top to bottom, projects (foldable, one summary line) → lead
2// conversations → their sub-conversations (indented), each folded to title, state, model·effort, time and a two-line
3// preview; unfolded: the full last message (Markdown), the ask and its buttons, PR links, « Ouvrir la conversation ».
4// Reads ~/.claude/veille/fil.json only (written by the watcher); nothing sent to a model, nothing added to a prompt.
5import { filterFil, flatten, hm, isNew, summaryLine, FILTERS } from '../lib/fil.mjs'
6import { archiveConfig } from '../lib/archive.mjs'
7
8const STATE_TONE = { en_cours: 'ok', attend: 'julian', bloquee: 'bad', terminee: 'dim', repos: 'info' }
9
10export function filLabels(cfg) {
11  const L = ((cfg && cfg.fil) || {}).labels || {}
12  return {
13    states: { en_cours: 'en cours', attend: 'attend Julian', bloquee: 'bloquée', terminee: 'terminée', repos: 'au repos', ...(L.states || {}) },
14    filters: { tout: 'Tout', attend: 'Attend Julian', bloquee: 'Bloquées', terminee_auj: "Terminées aujourd'hui", projet: 'Projet', ...(L.filters || {}) },
15    ...Object.fromEntries(Object.entries(L).filter(([k]) => k !== 'states' && k !== 'filters')),
16  }
17}
18
19// Labels of the night block (veille.config.json night.labels), shared by the Alertes tab and the wakes of the Fil.
20export function nightLabels(cfg) {
21  const L = ((cfg && cfg.night) || {}).labels || {}
22  return { title: 'Bilan de la nuit', night_button: 'Nuit', day_button: 'Jour', night_on: 'Nuit active depuis {time}', wakes: 'Réveils de la Veille', ...L,
23    wake_states: { en_cours: 'en cours', ok: 'conforme', ecart: 'écart', erreur: 'erreur', sans_resultat: 'sans résultat', ...(L.wake_states || {}) } }
24}
25
26const WAKE_TONE = { en_cours: 'info', ok: 'ok', ecart: 'bad', erreur: 'bad', sans_resultat: 'warn' }
27
28// One line per wake (newest first) and its result: what the Fil shows of ~/.claude/veille/wakes.json.
29export function wakeLines(wakes, cfg, max = 5) {
30  const NL = nightLabels(cfg)
31  return ((wakes && wakes.wakes) || []).slice(0, max).map((w) => ({
32    head: `${hm(w.at)} · ${w.title || w.target_cli || ''} · ${[String((w.want || {}).model || '').replace(/^claude-/, ''), (w.want || {}).effort, (w.want || {}).permission].filter(Boolean).join(' · ')} · ${NL.wake_states[w.status] || w.status}`,
33    tone: WAKE_TONE[w.status] || 'info',
34    detail: [w.reason, (w.diffs || []).length ? (w.diffs || []).join(' ; ') : '', w.result].filter(Boolean).map((x) => String(x).replace(/\s+/g, ' ').slice(0, 300)),
35  }))
36}
37
38export function drawWakes(el, cfg, wakes, T, col) {
39  const lines = wakeLines(wakes, cfg)
40  if (!lines.length) return null
41  const colors = cfg.colors || {}
42  return col([
43    T(nightLabels(cfg).wakes, { bold: true }),
44    ...lines.map((l) => col([T(l.head, { tone: l.tone }), ...l.detail.map((d) => T(d, { dim: true }))], { marginLeft: 2 })),
45  ], { borderStyle: 'round', ...(colors.dim ? { borderColor: colors.dim } : {}), paddingX: 1, marginBottom: 1 })
46}
47
48const fillT = (t, v) => String(t || '').replace(/\{(\w+)\}/g, (m, k) => (v[k] !== undefined ? String(v[k]) : m))
49
50// The text a terminal render shows (and the tests read): one line per project and per conversation.
51export function filText(fil, cfg, ui = {}, read = null, nowMs = Date.now(), width = 75) {
52  const L = filLabels(cfg)
53  const out = []
54  const cut = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s)
55  if (!fil) return [L.missing || 'fil.json absent']
56  out.push(cut(`${L.title || 'Fil'} · ${fil.total} conv · ${fillT(L.julian_counter || 'attend Julian : {n}', { n: fil.julian || 0 })} · ${hm(fil.generated)}`))
57  const view = filterFil(fil, ui.filter || 'tout', ui.project || '', nowMs)
58  for (const p of view.projects) {
59    out.push('')
60    out.push(cut(`▾ ${p.name} — ${summaryLine(p, { attend: L.states.attend })}`))
61    for (const c of flatten(p.convs)) {
62      const pad = '  '.repeat(c.depth + 1)
63      out.push(cut(`${pad}● ${L.states[c.state] || c.state} · ${c.title} · ${[c.model, c.effort].filter(Boolean).join('·')} · ${hm(c.msg_at || c.last_at)}${isNew(c, read) ? ' · ' + (L.new || 'Nouveau') : ''}`))
64      if (c.ask) out.push(cut(`${pad}  ? ${c.ask}`))
65      if (c.preview) out.push(cut(`${pad}  ${c.preview}`))
66    }
67    if (p.hidden) out.push(`  ${fillT(L.more || '+ {n} autres', { n: p.hidden })}`)
68  }
69  if (!view.projects.length) out.push(L.none || 'Aucune conversation.')
70  return out
71}
72
73// « À archiver (N) »: folded to its title; unfolded, one line per candidate (title, project, reason) and a button that
74// copies every id. h.archive is ~/.claude/veille/archive.json; h.toggleArchive and h.copyIds are the pane's handlers.
75export function archiveLines(arch, cfg) {
76  const L = archiveConfig(cfg).labels
77  if (!arch) return [L.missing || 'archive.json absent']
78  const c = arch.candidates || []
79  return [fillT(L.title || 'À archiver ({n})', { n: c.length }), ...c.map((x) => `${x.title} · ${x.project} · ${x.reason}`)]
80}
81
82function drawArchive(el, cfg, arch, open, h, T, row, col, btn) {
83  const L = archiveConfig(cfg).labels
84  const c = (arch && arch.candidates) || []
85  const title = btn(`${open ? '▾' : '▸'} ${fillT(L.title || 'À archiver ({n})', { n: arch ? c.length : '?' })}`, h.toggleArchive, false, 'fil-archive')
86  if (!open) return row([title], { alignItems: 'center', marginBottom: 1 })
87  if (!arch) return col([title, T(L.missing || 'archive.json absent', { dim: true })], { marginBottom: 1 })
88  return col([
89    title,
90    ...(c.length ? c.map((x) => T(`${x.title} · ${x.project} · ${x.reason}`, { dim: true })) : [T(L.none || 'Rien à archiver.', { dim: true })]),
91    c.length ? row([btn(L.copy_ids || 'Copier la liste des ids', (press) => h.copyIds(c.map((x) => x.sid).join('\n'), c.length, press), true, 'fil-archive-copy')]) : null,
92    T(L.note || '', { dim: true }),
93  ], { marginLeft: 2, marginBottom: 1 })
94}
95
96// The « À archiver » tab of the Veille pane: the list always unfolded, « Copier la liste des ids », « Rafraîchir »
97// (runs the watcher's archiver command, which rewrites archive.json), and the note on who archives (the VEILLE
98// conversation, never the pane). h: { copyIds, refresh, msg, busy }.
99export function drawArchiveTab(el, cfg, arch, h) {
100  const colors = cfg.colors || {}
101  const L = archiveConfig(cfg).labels
102  const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: 'wrap', ...(o.tone && colors[o.tone] ? { color: colors[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
103  const row = (parts, o = {}) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, ...o, children: parts.filter(Boolean) })
104  const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
105  const btn = (label, onPress, primary, key) => el.Button({ children: [label], variant: primary ? 'primary' : 'secondary', onPress, ...(key ? { key } : {}) })
106  const c = (arch && arch.candidates) || []
107  return col([
108    row([
109      T(fillT(L.title || 'À archiver ({n})', { n: arch ? c.length : '?' }), { bold: true }),
110      arch && arch.generated ? T(`${hm(arch.generated)}${arch.open ? ` · ${arch.open} ouvertes` : ''}`, { dim: true }) : null,
111    ]),
112    ...(!arch ? [T(L.missing || 'archive.json absent', { tone: 'warn' })]
113      : c.length ? c.map((x) => T(`${x.title} · ${x.project} · ${x.reason}`, { dim: true })) : [T(L.none || 'Rien à archiver.', { dim: true })]),
114    row([
115      c.length ? btn(L.copy_ids || 'Copier la liste des ids', (press) => h.copyIds(c.map((x) => x.sid).join('\n'), c.length, press), true, 'fil-archive-copy') : null,
116      h.refresh ? btn(h.busy === 'archiver' ? '…' : L.refresh || 'Rafraîchir', h.refresh, false, 'archive-refresh') : null,
117    ]),
118    h.msg ? T(h.msg, { dim: true }) : null,
119    T(L.note || '', { dim: true }),
120  ])
121}
122
123export function drawFil(el, cfg, fil, ui, h) {
124  const colors = cfg.colors || {}
125  const L = filLabels(cfg)
126  const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: o.wrap || 'wrap', ...(o.tone && colors[o.tone] ? { color: colors[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
127  const row = (parts, o = {}) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, ...o, children: parts.filter(Boolean) })
128  const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
129  const btn = (label, onPress, primary, key) => el.Button({ children: [label], variant: primary ? 'primary' : 'secondary', onPress, ...(key ? { key } : {}) })
130  const link = (href, label) => (href && el.Link ? el.Link({ href, children: [label] }) : null)
131  const md = (text) => (el.Markdown ? el.Markdown({ children: [text] }) : T(text))
132
133  // the pane passes its one « attend Julian » count (view.mjs julianCount = lib/core/veille.mjs attendJulian)
134  const julian = h.julian !== undefined ? h.julian : (fil && fil.julian) || 0
135  const head = col([
136    row([
137      T(L.title || 'Fil des conversations', { bold: true }),
138      julian ? T(fillT(L.julian_counter || 'attend Julian : {n}', { n: julian }), { tone: 'julian', bold: true }) : null,
139      fil ? T(fillT(L.generated || 'lu à {time} ({age})', { time: hm(fil.generated), age: h.ago(h.now - fil.generated) }), { dim: true }) : null,
140    ]),
141    row([
142      ...FILTERS.map((f) => btn(L.filters[f] || f, () => h.filter(f), (ui.filter || 'tout') === f, `fil-filter-${f}`)),
143    ]),
144    ui.filter === 'projet' && fil ? row(fil.projects.map((p) => btn(p.name, () => h.project(p.name), ui.project === p.name))) : null,
145    row([btn(h.busy === 'fil' ? L.refreshing || 'Lecture…' : L.refresh || 'Rafraîchir', h.refresh), btn(L.mark_all || 'Tout marquer lu', h.markAll)]),
146    h.msg ? T(h.msg, { dim: true }) : null,
147  ], { marginBottom: 1 })
148  const archive = h.toggleArchive ? drawArchive(el, cfg, h.archive, !!ui.archiveOpen, h, T, row, col, btn) : null
149  const wakes = drawWakes(el, cfg, h.wakes, T, col)
150  if (!fil) return col([head, wakes, archive, T(L.missing || 'fil.json absent', { tone: 'warn' })])
151
152  const view = filterFil(fil, ui.filter || 'tout', ui.project || '', h.now)
153  const projOpen = (p) => (ui.openProj && p.name in ui.openProj ? ui.openProj[p.name] : (p.counts.attend || p.counts.en_cours || p.counts.bloquee) > 0)
154
155  const conv = (c) => {
156    const open = !!(ui.openConv || {})[c.sid]
157    const fresh = isNew(c, h.read)
158    const line = row([
159      btn(`${open ? '▾' : '▸'} ${c.title}`, () => h.toggleConv(c.sid), false, `fil-conv-${c.sid}`),
160      T(`● ${L.states[c.state] || c.state}`, { tone: STATE_TONE[c.state], bold: c.state === 'attend' }),
161      T([c.model.replace(/^claude-/, ''), c.effort].filter(Boolean).join(' · '), { dim: true }),
162      T(hm(c.msg_at || c.last_at), { dim: true }),
163      fresh ? T(L.new || 'Nouveau', { tone: 'warn', bold: true }) : null,
164      c.archived ? T('archivée', { dim: true }) : null,
165      c.parent_title ? T(fillT(L.under || 'sous {title}', { title: c.parent_title }), { dim: true }) : null,
166    ], { alignItems: 'center' })
167    const ask = c.ask ? T(`? ${c.ask}`, { tone: 'julian' }) : null
168    const preview = c.preview ? T(c.preview, { dim: !!c.dim || !fresh }) : T(L.empty_message || '(pas de message)', { dim: true })
169    if (!open) return col([line, ask, preview], { marginLeft: 2 * c.depth, marginBottom: 1 })
170    return col([
171      line, ask,
172      c.recap ? col([T(L.recap || 'Récap (wrap_up)', { bold: true }), T(c.recap)]) : null,
173      c.actions.length ? T(`${L.buttons || 'Boutons proposés'} : ${c.actions.join(' | ')}`, { dim: true }) : null,
174      T(L.message || 'Dernier message', { bold: true }),
175      c.text ? md(c.text) : T(L.empty_message || '(pas de message)', { dim: true }),
176      c.prs.length ? row([T(`${L.prs || 'PR/MR'} :`, { dim: true }), ...c.prs.map((p) => link(p.url, `${p.url.replace(/^https?:\/\/[^/]+\//, '')}${p.state ? ' (' + p.state + ')' : ''}`))]) : null,
177      // A pane's Link only opens https: (the engine draws a claude:// href as plain text), so the button hands the
178      // claude:// link to the OS, whose handler is the desktop app (it routes claude://claude.ai/epitaxy/...).
179      row([btn(L.open || 'Ouvrir la conversation', () => h.openConv(c.link), true, `fil-open-${c.sid}`), btn(L.copy_id || "Copier l'id", (press) => h.copy(c.sid, press), false, `fil-copy-${c.sid}`), T(c.sid, { dim: true })], { alignItems: 'center' }),
180      T(L.open_note || 'ouverture par le lien claude:// de l’app : non vérifiée', { dim: true }),
181    ], { marginLeft: 2 * c.depth, marginBottom: 1, borderStyle: 'round', ...(colors.dim ? { borderColor: colors.dim } : {}), paddingX: 1 })
182  }
183
184  const projects = view.projects.map((p) => {
185    const open = projOpen(p)
186    return col([
187      row([btn(`${open ? '▾' : '▸'} ${p.name}`, () => h.toggleProj(p.name, !open), false, `fil-proj-${p.name}`), T(summaryLine(p, { attend: L.states.attend }), { dim: !p.counts.attend, tone: p.counts.attend ? 'julian' : undefined })], { alignItems: 'center' }),
188      open ? col([...flatten(p.convs).map(conv), p.hidden ? T(fillT(L.more || '+ {n} autres', { n: p.hidden }), { dim: true }) : null], { marginLeft: 2, marginTop: 1 }) : null,
189    ], { marginBottom: 1 })
190  })
191  return col([head, wakes, archive, ...(projects.length ? projects : [T(L.none || 'Aucune conversation.', { dim: true })])])
192}
193