Deriva: tenete in rotta la nave del team, insieme, mentre i vostri Claude lavorano

hooks/register.tsx 459 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Membership, Reply, View } from '../types'
5import { ago, bar, COLORS, countdown, HAZARD_NAMES, levelColor, movesFor, parseJoin } from './format'
6
7const SERVER = 'https://mqhxhdhdmexkbhlmkjvu.supabase.co/functions/v1/deriva'
8const PANE = 'deriva'
9const TITLE = 'Deriva'
10const PANE_ROWS = 28
11const POLL_MS = 2000
12/** With the pane closed but Claude working, one sync every this many polls (about 14 s). */
13const CLOSED_POLL_EVERY = 7
14const CLOSE_DELAY_MS = 800
15const LOG_LINES = 6
16
17/** Tools that stop the turn until the person answers. */
18const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
19const ASKING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
20
21const SIGNAL_BUTTONS = [
22 { key: 'sig-ingegnere', hotkey: 'i', label: 'serve ingegnere', id: 'serve_ingegnere' },
23 { key: 'sig-navigatore', hotkey: 'n', label: 'serve navigatore', id: 'serve_navigatore' },
24 { key: 'sig-tecnico', hotkey: 't', label: 'serve tecnico', id: 'serve_tecnico' },
25 { key: 'sig-bravi', hotkey: 'b', label: 'bravi!', id: 'bravi' },
26]
27
28const HELP = [
29 'Deriva: tenete in rotta la nave del team mentre i vostri Claude lavorano.',
30 '/deriva nuova <nome> crea una nave e ti dà il codice da condividere',
31 '/deriva entra <CODICE> <nome> [ruolo] sali sulla nave del team (ingegnere, navigatore, tecnico)',
32 '/deriva apre il pannello',
33 '/deriva ruolo <ruolo> cambia ruolo (costa 3 di energia)',
34 '/deriva auto on|off apertura automatica a ogni prompt',
35 '/deriva esci lascia la nave su questo computer',
36].join('\n')
37
38const phase = atom({ plugin: 'deriva', key: 'phase' } as const, 'idle')
39const isAutoOpen = atom({ plugin: 'deriva', key: 'isAutoOpen' } as const, true)
40
41// Turns running now, the main loop's and its subagents': the room is open while any runs.
42const running = new Set<string>()
43let pendingClose: Timer | undefined
44let poller: Timer | undefined
45let pollCount = 0
46let isSyncing = false
47/** Undefined until read from the store. */
48let membership: Membership | null | undefined
49let view: View | null = null
50let receivedAt = 0
51let lastError: string | null = null
52let alertedJump = 0
53
54async function memberOf($: EngineInterface): Promise<Membership | null> {
55 if (membership === undefined) {
56 membership = ((await $.store.get('membership')) as Membership | undefined) ?? null
57 }
58
59 return membership
60}
61
62async function call($: EngineInterface, body: Record<string, unknown>): Promise<Reply> {
63 try {
64 const response = await $.http.fetch(SERVER, {
65 method: 'POST',
66 headers: { 'content-type': 'application/json' },
67 body: JSON.stringify(body),
68 })
69 const reply = JSON.parse(response.text) as Reply
70
71 return response.ok || reply.error ? reply : { error: `La nave non risponde (${response.status}).` }
72 } catch {
73 return { error: 'La nave non risponde: controlla la connessione.' }
74 }
75}
76
77async function apply($: EngineInterface, reply: Reply): Promise<void> {
78 if (reply.view) {
79 view = reply.view
80 receivedAt = await $.clock.now()
81 const jump = reply.view.jump
82 if (jump && !jump.isYours && jump.expiresAt !== alertedJump && (await read($, phase)) === 'working') {
83 alertedJump = jump.expiresAt
84 $.ui.toast(`🚀 ${jump.by} ha avviato un salto: premi 9 su Deriva per confermare entro 20 secondi!`)
85 }
86 }
87 lastError = reply.error ?? null
88 $.ui.invalidate('ui.render')
89}
90
91async function sync($: EngineInterface): Promise<void> {
92 const member = await memberOf($)
93 if (!member || isSyncing) return
94
95 isSyncing = true
96 try {
97 const isWaiting = (await read($, phase)) === 'working'
98 const { code, playerId, token } = member
99 await apply($, await call($, { op: 'sync', code, playerId, token, isWaiting }))
100 } finally {
101 isSyncing = false
102 }
103}
104
105async function poll($: EngineInterface): Promise<void> {
106 if (!(await memberOf($))) return
107
108 pollCount += 1
109 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
110 const isWorking = (await read($, phase)) === 'working'
111 if (isOpen || (isWorking && pollCount % CLOSED_POLL_EVERY === 0)) await sync($)
112}
113
114function startPolling($: EngineInterface): void {
115 poller ??= $.clock.every(POLL_MS, () => void poll($))
116}
117
118async function perform($: EngineInterface, action: string, target?: number): Promise<void> {
119 const member = await memberOf($)
120 if (!member) return
121
122 const current = await read($, phase)
123 if (current !== 'working') {
124 $.ui.toast(current === 'needs-you' ? 'Prima rispondi a Claude: ti sta aspettando.' : 'Si agisce solo mentre Claude lavora.')
125 return
126 }
127
128 const { code, playerId, token } = member
129 const reply = await call($, { op: 'act', code, playerId, token, action, target, isWaiting: true })
130 await apply($, reply)
131 if (reply.error) $.ui.toast(reply.error)
132}
133
134async function sendSignal($: EngineInterface, id: string): Promise<void> {
135 const member = await memberOf($)
136 if (!member) return
137
138 const { code, playerId, token } = member
139 const reply = await call($, { op: 'signal', code, playerId, token, signal: id })
140 await apply($, reply)
141 $.ui.toast(reply.error ?? "Segnale inviato all'equipaggio.")
142}
143
144/** Keeps the membership a create or join handed out and opens the bridge. */
145async function board($: EngineInterface, reply: Reply): Promise<string | null> {
146 if (!reply.code || !reply.playerId || !reply.token || !reply.view) return null
147
148 membership = {
149 code: reply.code,
150 playerId: reply.playerId,
151 token: reply.token,
152 nickname: reply.view.you.nickname,
153 }
154 await $.store.set('membership', membership)
155 await apply($, reply)
156 startPolling($)
157 await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: PANE_ROWS })
158
159 return reply.view.you.role
160}
161
162async function needsYou($: EngineInterface): Promise<void> {
163 if (running.size > 0) await update($, phase, () => 'needs-you')
164}
165
166async function backToWork($: EngineInterface): Promise<void> {
167 if (running.size > 0) await update($, phase, current => (current === 'needs-you' ? 'working' : current))
168}
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 await $.command.register({
173 name: 'deriva',
174 description: 'Deriva: la nave del team, si gioca mentre Claude lavora (/deriva nuova | entra | ruolo | auto | esci)',
175 })
176 const isAuto = (await $.store.get('isAutoOpen')) !== false
177 await update($, isAutoOpen, () => isAuto)
178 if (await memberOf($)) startPolling($)
179
180 return next(e)
181 })
182
183 on('prompt.submit', async ($, e, next) => {
184 const result = await next(e)
185 pendingClose?.cancel()
186 pendingClose = undefined
187 if ((await memberOf($)) && (await read($, isAutoOpen))) {
188 void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
189 }
190
191 return result
192 })
193
194 on('turn.start', async ($, e, next) => {
195 running.add(e.turnId)
196 await update($, phase, () => 'working')
197 if (await memberOf($)) {
198 startPolling($)
199 void sync($)
200 }
201
202 return next(e)
203 })
204
205 on('turn.complete', async ($, e, next) => {
206 const result = await next(e)
207 running.delete(e.turnId)
208 if (running.size > 0 || e.agentId !== undefined) return result
209
210 await update($, phase, () => 'idle')
211 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
212 if (isOpen && view) {
213 const threats = view.hazards.length
214 $.ui.toast(
215 `Deriva: nave a ${Math.floor(view.ship.distance)}/${view.destination} anni luce, ${threats} ${threats === 1 ? 'minaccia' : 'minacce'} in arrivo`,
216 )
217 }
218 if (isOpen && (await read($, isAutoOpen))) {
219 pendingClose = $.clock.after(CLOSE_DELAY_MS, () => {
220 pendingClose = undefined
221 void $.ui.close({ id: PANE })
222 })
223 }
224
225 return result
226 })
227
228 on('classic.PermissionRequest', async ($, e, next) => {
229 await needsYou($)
230
231 return next(e)
232 })
233
234 on('classic.Notification', async ($, e, next) => {
235 if (ASKING_NOTIFICATIONS.has(e.notification_type)) await needsYou($)
236
237 return next(e)
238 })
239
240 on('tool.call', async ($, e, next) => {
241 if (ASKING_TOOLS.has(String(e.tool))) await needsYou($)
242 const ran = await next(e)
243 await backToWork($)
244
245 return ran
246 })
247
248 on('command.run', { command: 'deriva' }, async ($, e) => {
249 const [sub = '', ...words] = e.args.trim().split(/\s+/).filter(Boolean)
250
251 if (sub === 'nuova') {
252 const reply = await call($, { op: 'create', nickname: words.join(' ') })
253 if (!(await board($, reply))) return { text: reply.error ?? 'Non sono riuscito a creare la nave.' }
254
255 return {
256 text: `Nave creata: codice ${reply.code}. I compagni salgono con /deriva entra ${reply.code} <nome>. Tu sei l'ingegnere.`,
257 }
258 }
259
260 if (sub === 'entra') {
261 const parsed = parseJoin(words)
262 if (!parsed) return { text: 'Uso: /deriva entra <CODICE> <nome> [ingegnere|navigatore|tecnico]' }
263 const reply = await call($, { op: 'join', ...parsed })
264 const role = await board($, reply)
265 if (!role) return { text: reply.error ?? 'Non sono riuscito a salire a bordo.' }
266
267 return { text: `Sei a bordo della nave ${reply.code} come ${role}. Si gioca mentre Claude lavora.` }
268 }
269
270 const member = await memberOf($)
271
272 if (sub === 'ruolo') {
273 if (!member) return { text: 'Prima sali su una nave: /deriva entra <CODICE> <nome>' }
274 const { code, playerId, token } = member
275 const reply = await call($, { op: 'role', code, playerId, token, role: words[0] ?? '' })
276 await apply($, reply)
277
278 return { text: reply.error ?? `Ora sei ${reply.view?.you.role ?? words[0]}.` }
279 }
280
281 if (sub === 'esci') {
282 membership = null
283 view = null
284 await $.store.delete('membership')
285 await $.ui.close({ id: PANE })
286
287 return { text: 'Hai lasciato la nave su questo computer. Per tornare: /deriva entra <CODICE> <nome>' }
288 }
289
290 if (sub === 'auto' && (words[0] === 'on' || words[0] === 'off')) {
291 const isAuto = words[0] === 'on'
292 await update($, isAutoOpen, () => isAuto)
293 await $.store.set('isAutoOpen', isAuto)
294
295 return { text: isAuto ? 'Deriva si aprirà a ogni prompt.' : 'Deriva non si aprirà più da solo: usa /deriva.' }
296 }
297
298 if (sub !== '' || !member) return { text: HELP }
299
300 await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: PANE_ROWS })
301 void sync($)
302
303 return { text: `Plancia della nave ${member.code} aperta.` }
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Text, Button } = $.ui.resolve(e)
308 const current = await read($, phase)
309 const member = await memberOf($)
310
311 if (!member) {
312 return (
313 <Box flexDirection="column">
314 <Text bold>🚀 Deriva</Text>
315 <Text>Non sei ancora su una nave.</Text>
316 <Text dimColor wrap="wrap">
317 {'/deriva nuova <nome> per crearne una, /deriva entra <CODICE> <nome> per salire su quella del team.'}
318 </Text>
319 </Box>
320 )
321 }
322 const shown = view
323 if (!shown) {
324 return <Text dimColor>Collegamento alla nave {member.code}…</Text>
325 }
326
327 const now = shown.now + ((await $.clock.now()) - receivedAt)
328 const ship = shown.ship
329 const isActive = current === 'working'
330 const gauges = [
331 ['Reattore', ship.reactor],
332 ['Scafo', ship.hull],
333 ['Ossigeno', ship.oxygen],
334 ['Scudi', ship.shields],
335 ] as const
336 const jump = shown.jump && shown.jump.expiresAt > now ? shown.jump : null
337 const status =
338 current === 'working'
339 ? "Claude sta lavorando: sei in sala d'attesa, +1 energia ogni 30 secondi."
340 : current === 'needs-you'
341 ? '⚠ Claude ha bisogno di te: rispondi, poi torni in plancia.'
342 : '🔒 Claude è fermo: puoi guardare la nave, ma si agisce solo mentre lavora.'
343
344 return (
345 <Box flexDirection="column">
346 <Box gap={2}>
347 <Text bold>🚀 DERIVA</Text>
348 <Text>{shown.code}</Text>
349 <Text dimColor>missione {shown.mission}</Text>
350 </Box>
351 <Box gap={1}>
352 <Text>{'Rotta'.padEnd(9)}</Text>
353 <Text color={COLORS.accent}>{bar(ship.distance, shown.destination, 20)}</Text>
354 <Text>
355 {Math.floor(ship.distance)}/{shown.destination} AL
356 </Text>
357 {ship.courseUntil > now && <Text color={COLORS.accent}>⚡ {countdown(ship.courseUntil - now)}</Text>}
358 </Box>
359 {gauges.map(([label, value]) => (
360 <Box key={`gauge-${label}`} gap={1}>
361 <Text>{label.padEnd(9)}</Text>
362 <Text color={levelColor(value)}>{bar(value, 100, 20)}</Text>
363 <Text>{String(Math.round(value)).padStart(3)}</Text>
364 </Box>
365 ))}
366
367 <Text bold>Minacce</Text>
368 {shown.hazards.length === 0 && <Text dimColor> nessun contatto sui sensori</Text>}
369 {shown.hazards.map(hazard => (
370 <Text color={hazard.kind === null ? COLORS.warn : hazard.kind === 'relitto' ? COLORS.accent : COLORS.bad}>
371 {' '}
372 {hazard.kind === null ? '? Contatto sconosciuto' : `${hazard.kind === 'relitto' ? '🛰' : '⚠'} ${HAZARD_NAMES[hazard.kind]}`} tra{' '}
373 {countdown(hazard.hitsAt - now)}
374 </Text>
375 ))}
376
377 <Text bold>Equipaggio</Text>
378 <Box gap={2} flexWrap="wrap">
379 {shown.crew.map(c => (
380 <Text color={c.isWaiting ? COLORS.good : COLORS.dim}>
381 {c.isWaiting ? '●' : '○'} {c.nickname} {c.role}
382 {c.isYou ? ' (tu)' : ''}
383 </Text>
384 ))}
385 </Box>
386
387 <Box gap={1}>
388 <Text>{'Energia'.padEnd(9)}</Text>
389 <Text color={COLORS.accent}>{bar(shown.you.energy, shown.you.maxEnergy, shown.you.maxEnergy)}</Text>
390 <Text>
391 {shown.you.energy}/{shown.you.maxEnergy}
392 </Text>
393 </Box>
394 <Box gap={2} flexWrap="wrap">
395 {movesFor(shown).map(move => (
396 <Button
397 key={move.key}
398 label={`${move.label} (${move.cost})`}
399 hotkey={move.hotkey}
400 plain
401 dimColor={!isActive || shown.you.energy < move.cost}
402 onPress={() => void perform($, move.action, move.target)}
403 />
404 ))}
405 {!jump && (
406 <Button
407 key="jump"
408 label="salto (2)"
409 hotkey="8"
410 plain
411 dimColor={!isActive || ship.reactor < 60}
412 onPress={() => void perform($, 'salto')}
413 />
414 )}
415 {jump && !jump.isYours && (
416 <Button
417 key="confirm-jump"
418 label={`conferma il salto di ${jump.by} (1)`}
419 hotkey="9"
420 plain
421 variant="primary"
422 onPress={() => void perform($, 'conferma_salto')}
423 />
424 )}
425 </Box>
426 {jump?.isYours && (
427 <Text color={COLORS.warn}>Salto avviato: serve la conferma di un compagno entro {countdown(jump.expiresAt - now)}.</Text>
428 )}
429 <Box gap={2} flexWrap="wrap">
430 <Text dimColor>Segnali</Text>
431 {SIGNAL_BUTTONS.map(signal => (
432 <Button
433 key={signal.key}
434 label={signal.label}
435 hotkey={signal.hotkey}
436 plain
437 dimColor
438 onPress={() => void sendSignal($, signal.id)}
439 />
440 ))}
441 </Box>
442
443 <Text bold>Diario di bordo</Text>
444 {shown.log.slice(-LOG_LINES).map((entry, index) => (
445 <Box key={`log-${index}`} gap={1}>
446 <Text dimColor>{ago(now - entry.at).padEnd(6)}</Text>
447 <Text wrap="wrap">{entry.text}</Text>
448 </Box>
449 ))}
450
451 <Text color={current === 'needs-you' ? COLORS.warn : undefined} dimColor={current === 'idle'} wrap="wrap">
452 {status}
453 </Text>
454 {lastError && <Text color={COLORS.bad}>{lastError}</Text>}
455 </Box>
456 )
457 })
458}
459hooks/format.ts 98 lines1import type { HazardKind, Role, View } from '../types'
2
3export const HAZARD_NAMES: Record<HazardKind, string> = {
4 asteroidi: 'Campo di asteroidi',
5 tempesta: 'Tempesta ionica',
6 relitto: 'Relitto alla deriva',
7}
8
9export const COLORS = {
10 good: '#22c55e',
11 warn: '#eab308',
12 bad: '#ef4444',
13 dim: '#6b7280',
14 accent: '#38bdf8',
15}
16
17export type Move = {
18 key: string
19 hotkey: string
20 label: string
21 action: string
22 target?: number
23 cost: number
24}
25
26export function bar(value: number, max: number, width: number): string {
27 const filled = Math.round((Math.max(0, Math.min(max, value)) / max) * width)
28
29 return '█'.repeat(filled) + '░'.repeat(width - filled)
30}
31
32export function levelColor(value: number): string {
33 return value < 25 ? COLORS.bad : value < 50 ? COLORS.warn : COLORS.good
34}
35
36export function countdown(ms: number): string {
37 const seconds = Math.max(0, Math.round(ms / 1000))
38
39 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
40}
41
42export function ago(ms: number): string {
43 const minutes = Math.floor(ms / 60_000)
44 if (minutes < 1) return 'ora'
45 if (minutes < 60) return `${minutes}m fa`
46 const hours = Math.floor(minutes / 60)
47
48 return hours < 24 ? `${hours}h fa` : `${Math.floor(hours / 24)}g fa`
49}
50
51/** What the player's role can do on this view, in hotkey order. */
52export function movesFor(view: View): Move[] {
53 const role: Role = view.you.role
54 if (role === 'ingegnere') {
55 return [
56 { key: 'act-reattore', hotkey: '1', label: 'ripara reattore +20', action: 'ripara_reattore', cost: 1 },
57 { key: 'act-scafo', hotkey: '2', label: 'ripara scafo +15', action: 'ripara_scafo', cost: 1 },
58 { key: 'act-scudi', hotkey: '3', label: 'carica scudi +25', action: 'carica_scudi', cost: 1 },
59 ]
60 }
61 if (role === 'tecnico') {
62 return [
63 { key: 'act-scansiona', hotkey: '1', label: 'scansiona', action: 'scansiona', cost: 1 },
64 { key: 'act-ossigeno', hotkey: '2', label: 'ricicla ossigeno +20', action: 'ricicla_ossigeno', cost: 1 },
65 ]
66 }
67
68 const moves: Move[] = [{ key: 'act-rotta', hotkey: '1', label: 'rotta veloce 60m', action: 'traccia_rotta', cost: 2 }]
69 const threat = view.hazards.find(hazard => hazard.kind === 'asteroidi' || hazard.kind === 'tempesta')
70 if (threat?.kind) {
71 moves.push({
72 key: 'act-evita',
73 hotkey: '2',
74 label: `evita: ${HAZARD_NAMES[threat.kind].toLowerCase()}`,
75 action: 'evita',
76 target: threat.id,
77 cost: 2,
78 })
79 }
80 const wreck = view.hazards.find(hazard => hazard.kind === 'relitto')
81 if (wreck) {
82 moves.push({ key: 'act-aggancia', hotkey: '3', label: 'aggancia relitto', action: 'aggancia', target: wreck.id, cost: 2 })
83 }
84
85 return moves
86}
87
88/** Splits `/deriva entra DRV-AB12 Anna Rossi tecnico` into code, nickname and role. */
89export function parseJoin(words: string[]): { code: string; nickname: string; role?: Role } | null {
90 const [code, ...rest] = words
91 if (!code || rest.length === 0) return null
92 const last = rest[rest.length - 1]
93 const role = last === 'ingegnere' || last === 'navigatore' || last === 'tecnico' ? last : undefined
94 const nickname = (role ? rest.slice(0, -1) : rest).join(' ')
95
96 return nickname ? { code: code.toUpperCase(), nickname, role } : null
97}
98types/index.d.ts 46 lines1// The deriva edge function's answers, as engine.ts `toView` builds them.
2
3export type Phase = 'idle' | 'working' | 'needs-you'
4
5export type Role = 'ingegnere' | 'navigatore' | 'tecnico'
6
7export type HazardKind = 'asteroidi' | 'tempesta' | 'relitto'
8
9export type Ship = {
10 reactor: number
11 hull: number
12 oxygen: number
13 shields: number
14 distance: number
15 courseUntil: number
16}
17
18export type View = {
19 code: string
20 now: number
21 mission: number
22 destination: number
23 ship: Ship
24 hazards: { id: number; kind: HazardKind | null; hitsAt: number }[]
25 crew: { nickname: string; role: Role; isWaiting: boolean; isYou: boolean }[]
26 you: { nickname: string; role: Role; energy: number; maxEnergy: number }
27 jump: { by: string; isYours: boolean; expiresAt: number } | null
28 log: { at: number; text: string }[]
29}
30
31export type Reply = {
32 view?: View
33 error?: string
34 code?: string
35 playerId?: string
36 token?: string
37}
38
39export type Membership = { code: string; playerId: string; token: string; nickname: string }
40
41declare module 'claude-code' {
42 interface PluginState {
43 deriva: { phase: Phase; isAutoOpen: boolean }
44 }
45}
46