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…

« Veille », one entry of the Mods bar: one pane, four tabs. Read-only, no model call, nothing added to a prompt.
| Tab | Command | What it shows | Reads |
|---|---|---|---|
| Alertes | /veille or /veille alertes | the 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 fil | every conversation's last message, by project, lead → sub-conversations | ~/.claude/veille/fil.json |
| À archiver | /veille archiver | finished 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 export | export-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.
/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.
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.
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.
« 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).
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.
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.
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.
hooks/register.mjs 410 lines1// 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}
410lib/modes.mjs 66 lines1// 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}
66lib/fil.mjs 365 lines1// 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}
365lib/archive.mjs 101 lines1// 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}
101lib/export-view.mjs 70 lines1// 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}
70hooks/view.mjs 83 lines1// 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}
83hooks/fil-view.mjs 193 lines1// 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