@@ -47,10 +47,12 @@ import {
4747 readLogTail ,
4848 readSettingsView ,
4949 readTriggers ,
50+ readPauseWindows ,
5051 listRunIds ,
5152 setQueuePaused ,
5253 writeSettings ,
5354 writeTriggers ,
55+ writePauseWindows ,
5456 enqueueDispatchRun ,
5557 KNOWN_KEYS ,
5658} from "./read-model.mjs" ;
@@ -366,6 +368,83 @@ function registerTools(pi: ExtensionAPI): void {
366368 } ,
367369 } ) ;
368370
371+ pi . registerTool ( {
372+ name : "dispatch_pauses" ,
373+ label : "pi-dispatch pause windows" ,
374+ description :
375+ "Read-only. Lists the scheduled pause windows (per folder/repo quiet hours) with their array index. " +
376+ "Use the index for dispatch_pause_delete." ,
377+ parameters : Type . Object ( { } ) ,
378+ async execute ( ) {
379+ const paths = resolvePaths ( process . env ) ;
380+ const p = readPauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath } ) ;
381+ const data = Array . isArray ( p ?. windows ) ? p . windows . map ( ( w : any , index : number ) => ( { index, ...w } ) ) : p ;
382+ return toolText ( JSON . stringify ( data ) ) ;
383+ } ,
384+ } ) ;
385+
386+ pi . registerTool ( {
387+ name : "dispatch_pause_add" ,
388+ label : "pi-dispatch add pause window" ,
389+ description :
390+ "Adds a scheduled pause window and applies it live: runs for `scope` (a repo \"owner/name\", a local " +
391+ "folder path, or \"*\" for all) are DEFERRED between `from` and `to` (\"HH:MM\" 24h; from>to = overnight) " +
392+ "and resume automatically after — nothing is dropped, and deferring costs no budget. Optional `tz` (IANA, " +
393+ "default UTC), `days` (mon..sun), `dateFrom`/`dateTo` (\"YYYY-MM-DD\"). The operator MUST approve a confirm " +
394+ "dialog showing the window; refused with no interactive operator." ,
395+ executionMode : "sequential" ,
396+ parameters : Type . Object ( {
397+ scope : Type . String ( ) ,
398+ from : Type . String ( ) ,
399+ to : Type . String ( ) ,
400+ tz : Type . Optional ( Type . String ( ) ) ,
401+ days : Type . Optional ( Type . Array ( Type . String ( ) ) ) ,
402+ dateFrom : Type . Optional ( Type . String ( ) ) ,
403+ dateTo : Type . Optional ( Type . String ( ) ) ,
404+ } ) ,
405+ async execute ( _id , params , _signal , _onUpdate , ctx ) {
406+ const paths = resolvePaths ( process . env ) ;
407+ const w = buildPauseWindow ( params ) ;
408+ const result = await confirmedWrite (
409+ ctx ,
410+ { title : "Add pause window" , message : `Add to pause-windows.json:\n${ JSON . stringify ( w ) } ` } ,
411+ ( ) => {
412+ const res = writePauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath , mutate : ( list : any [ ] ) => [ ...list , w ] } ) ;
413+ if ( res . invalid ) throw new Error ( `rejected: ${ res . invalid } ` ) ;
414+ return { applied : true , added : w } ;
415+ } ,
416+ ) ;
417+ return toolText ( JSON . stringify ( result ) ) ;
418+ } ,
419+ } ) ;
420+
421+ pi . registerTool ( {
422+ name : "dispatch_pause_delete" ,
423+ label : "pi-dispatch delete pause window" ,
424+ description :
425+ "Removes a scheduled pause window (by array index from dispatch_pauses) and applies it live. The operator " +
426+ "MUST approve a confirm dialog showing the window; refused with no interactive operator." ,
427+ executionMode : "sequential" ,
428+ parameters : Type . Object ( { index : Type . Integer ( { minimum : 0 } ) } ) ,
429+ async execute ( _id , params , _signal , _onUpdate , ctx ) {
430+ const paths = resolvePaths ( process . env ) ;
431+ const p = readPauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath } ) ;
432+ const list = Array . isArray ( p ?. windows ) ? p . windows : [ ] ;
433+ const cur = list [ params . index ] ;
434+ if ( ! cur ) throw new Error ( `no pause window at index ${ params . index } (have ${ list . length } )` ) ;
435+ const result = await confirmedWrite (
436+ ctx ,
437+ { title : `Delete pause window #${ params . index + 1 } ` , message : `Remove pause window #${ params . index + 1 } : ${ cur . scope } ${ cur . from } -${ cur . to } ${ cur . tz } ` } ,
438+ ( ) => {
439+ const res = writePauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath , mutate : ( l : any [ ] ) => l . filter ( ( _ , i ) => i !== params . index ) } ) ;
440+ if ( res . invalid ) throw new Error ( `rejected: ${ res . invalid } ` ) ;
441+ return { applied : true , deletedIndex : params . index } ;
442+ } ,
443+ ) ;
444+ return toolText ( JSON . stringify ( result ) ) ;
445+ } ,
446+ } ) ;
447+
369448 pi . registerTool ( {
370449 name : "dispatch_trigger_delete" ,
371450 label : "pi-dispatch delete trigger" ,
@@ -455,6 +534,26 @@ function buildTriggerEntry(kind: string, f: any): any {
455534 return null ;
456535}
457536
537+ /**
538+ * Build one pause-window entry from a kind-less field bag (shared by the `dispatch_pause_add` tool and the
539+ * operator dialog). Required scope/from/to; optional tz/days/dateFrom/dateTo included only when non-blank so
540+ * an omitted field drops out of the JSON. `days` accepts an array (tool) or a space-separated string (dialog).
541+ * All value validation (time format, IANA tz, weekday names, date format) lives in the shared
542+ * `parsePauseWindows`, which the write goes through — a bad value is rejected there, never written.
543+ */
544+ function buildPauseWindow ( f : any ) : any {
545+ const w : any = { scope : String ( f . scope ?? "" ) . trim ( ) , from : String ( f . from ?? "" ) . trim ( ) , to : String ( f . to ?? "" ) . trim ( ) } ;
546+ const tz = optStr ( f . tz ) ;
547+ const days = asWords ( f . days ) ;
548+ const dateFrom = optStr ( f . dateFrom ) ;
549+ const dateTo = optStr ( f . dateTo ) ;
550+ if ( tz ) w . tz = tz ;
551+ if ( days . length > 0 ) w . days = days ;
552+ if ( dateFrom ) w . dateFrom = dateFrom ;
553+ if ( dateTo ) w . dateTo = dateTo ;
554+ return w ;
555+ }
556+
458557/** Normalise a labels/action field to a trimmed non-empty string list, accepting an array or a string. */
459558function asWords ( x : any ) : string [ ] {
460559 if ( Array . isArray ( x ) ) return x . map ( ( s ) => String ( s ) . trim ( ) ) . filter ( Boolean ) ;
@@ -638,9 +737,56 @@ export async function handleDashboardAction(result: any, paths: any, ctx: any):
638737 return deleteTriggerEntry ( paths , ui , notify , result . index ) ;
639738 case "editSettings" :
640739 return editSettingsViaDialogs ( paths , ui , notify ) ;
740+ case "managePauses" :
741+ return managePausesViaDialogs ( paths , ui , notify ) ;
641742 }
642743}
643744
745+ /** Pause-window management: pick add or delete, then run the matching dialog. Keeps one overlay key (`w`). */
746+ async function managePausesViaDialogs ( paths : any , ui : any , notify : Notify ) : Promise < void > {
747+ const action = await ui . select ( "Pause windows" , [ "Add a pause window" , "Delete a pause window" ] ) ;
748+ if ( ! action ) return ;
749+ if ( action . startsWith ( "Add" ) ) return addPauseWindowViaDialogs ( paths , ui , notify ) ;
750+ return deletePauseWindowViaDialogs ( paths , ui , notify ) ;
751+ }
752+
753+ /** Add a pause window: scope + from/to, then the optional tz/days/date bounds (blank = omit). Validated + live. */
754+ async function addPauseWindowViaDialogs ( paths : any , ui : any , notify : Notify ) : Promise < void > {
755+ const scope = await ui . input ( "scope — a repo \"owner/name\", a local folder path, or \"*\" for all" , "" ) ;
756+ if ( scope === undefined || scope . trim ( ) === "" ) return ;
757+ const from = await ui . input ( "from — pause start \"HH:MM\" 24h (from > to = overnight)" , "22:00" ) ;
758+ if ( from === undefined ) return ;
759+ const to = await ui . input ( "to — resume time \"HH:MM\" 24h" , "06:00" ) ;
760+ if ( to === undefined ) return ;
761+ const tz = await ui . input ( "tz — IANA timezone (blank = UTC), e.g. Europe/Amsterdam" , "" ) ;
762+ if ( tz === undefined ) return ;
763+ const days = await ui . input ( "days — space-separated weekdays to restrict to (blank = every day), e.g. mon tue" , "" ) ;
764+ if ( days === undefined ) return ;
765+ const dateFrom = await ui . input ( "dateFrom — only on/after \"YYYY-MM-DD\" (blank = no bound)" , "" ) ;
766+ if ( dateFrom === undefined ) return ;
767+ const dateTo = await ui . input ( "dateTo — only on/before \"YYYY-MM-DD\" (blank = no bound)" , "" ) ;
768+ if ( dateTo === undefined ) return ;
769+ const w = buildPauseWindow ( { scope, from, to, tz, days, dateFrom, dateTo } ) ;
770+ const res = writePauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath , mutate : ( list : any [ ] ) => [ ...list , w ] } ) ;
771+ notify ?.( res . ok ? `pause window added (live) — ${ w . scope } ${ w . from } -${ w . to } ` : `add rejected: ${ res . invalid } ` , res . ok ? "info" : "error" ) ;
772+ }
773+
774+ /** Delete a pause window: select which (by a scope/time label), confirm, remove. */
775+ async function deletePauseWindowViaDialogs ( paths : any , ui : any , notify : Notify ) : Promise < void > {
776+ const p = readPauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath } ) ;
777+ const list : any [ ] = Array . isArray ( p ?. windows ) ? p . windows : [ ] ;
778+ if ( list . length === 0 ) { notify ?.( "no pause windows to delete" , "info" ) ; return ; }
779+ const labels = list . map ( ( w , i ) => `#${ i + 1 } ${ w . scope } ${ w . from } -${ w . to } ${ w . tz } ${ w . days ? ` [${ w . days . join ( "," ) } ]` : "" } ` ) ;
780+ const picked = await ui . select ( "Delete which pause window" , labels ) ;
781+ if ( ! picked ) return ;
782+ const index = labels . indexOf ( picked ) ;
783+ if ( index < 0 ) return ;
784+ const ok = await ui . confirm ( "Delete pause window" , `Remove ${ labels [ index ] } ?` ) ;
785+ if ( ! ok ) return ;
786+ const res = writePauseWindows ( { pauseWindowsPath : paths . pauseWindowsPath , mutate : ( l : any [ ] ) => l . filter ( ( _ , i ) => i !== index ) } ) ;
787+ notify ?.( res . ok ? `pause window #${ index + 1 } deleted (live)` : `delete rejected: ${ res . invalid } ` , res . ok ? "info" : "error" ) ;
788+ }
789+
644790/** Add a trigger: kind-first (the on x run diagonal is locked by construction), then the per-kind fields. */
645791async function addTriggerViaDialogs ( paths : any , ui : any , notify : Notify ) : Promise < void > {
646792 const kind = await ui . select ( "Add trigger — kind" , [ "cron" , "label" , "comment" , "pull_request" ] ) ;
0 commit comments