@@ -9,6 +9,8 @@ import { IdentityClient } from './gateway/identity.js'
99import { OutboxSink , defaultOutboxDir } from './gateway/outbox_sink.js'
1010import { Proxy } from './proxy.js'
1111import { Recorder } from './recorder.js'
12+ import { defaultPidFilePath } from './runtime/paths.js'
13+ import { removePidFile , writePidFile } from './runtime/pid_file.js'
1214import { ControlPlane } from './server/control_plane.js'
1315import { defaultSinkDir as defaultIngestSinkDir } from './server/ingest.js'
1416import { FileSink } from './sinks/file.js'
@@ -29,7 +31,7 @@ const PARQUET_PARTITION_DIMENSIONS = ['gateway_id', 'signal']
2931/**
3032 * @import { Server } from 'node:http'
3133 * @import { CollectivusConfig, ListenerFactory, StartedListener } from './types.js'
32- * @import { ConfigResult, ErrorResult, HotReloadWiring, ParseResult } from './cli/types.d.ts'
34+ * @import { ConfigResult, ErrorResult, HotReloadWiring, LocalReloadWiring, ParseResult } from './cli/types.d.ts'
3335 * @import { ConfigChangedEvent } from './gateway/types.d.ts'
3436 * @import { IngestSignal } from './server/types.d.ts'
3537 */
@@ -52,6 +54,7 @@ Commands:
5254 ctvs detach [--client claude|codex|all]
5355 Restore Claude Code and/or Codex config
5456 ctvs status Report daemon, config, recordings, attach state
57+ ctvs gascity <subcommand> [...] Manage gascity supervisor capture sources
5558 ctvs export --config <path|url> [...] Convert recorded JSONL to Parquet
5659 ctvs query <command> [...] Query local recordings through Parquet cache
5760 ctvs collect <file.jsonl> --name <name> Add external JSONL as a query table
@@ -177,9 +180,11 @@ function isHttpUrl(value) {
177180 * stdout?: { write: (s: string) => void },
178181 * stderr?: { write: (s: string) => void },
179182 * onShutdownRequested?: (handler: (signal: string) => void) => void,
183+ * onSighupRequested?: (handler: () => void) => void,
180184 * isTTY?: boolean,
181185 * runInit?: () => Promise<number>,
182186 * identityPersistedPath?: string,
187+ * pidFilePath?: string,
183188 * }} [hooks]
184189 * @returns {Promise<number> }
185190 */
@@ -235,12 +240,23 @@ export async function run(argv, env, hooks = {}) {
235240 return 0
236241 }
237242
238- return runWithConfig ( config , env , {
243+ /** @type {Parameters<typeof runWithConfig>[2] } */
244+ const childHooks = {
239245 stdout,
240246 stderr,
241247 onShutdownRequested,
242248 identityPersistedPath : hooks . identityPersistedPath ,
243- } )
249+ }
250+ if ( hooks . onSighupRequested !== undefined ) childHooks . onSighupRequested = hooks . onSighupRequested
251+ if ( hooks . pidFilePath !== undefined ) childHooks . pidFilePath = hooks . pidFilePath
252+ // Only wire SIGHUP-driven reload when the daemon was started from a
253+ // re-readable config source. `--config-env` configs live in process env,
254+ // so a SIGHUP can't pick up new values without a restart anyway; the
255+ // gateway-mode reload path (configClient) covers `--config-endpoint`.
256+ if ( parsed . configPath !== undefined && ! isHttpUrl ( parsed . configPath ) ) {
257+ childHooks . localConfigPath = parsed . configPath
258+ }
259+ return runWithConfig ( config , env , childHooks )
244260}
245261
246262/**
@@ -266,8 +282,18 @@ function loadConfigFromEnv(envName, env, opts) {
266282 * stdout?: { write: (s: string) => void },
267283 * stderr?: { write: (s: string) => void },
268284 * onShutdownRequested?: (handler: (signal: string) => void) => void,
285+ * onSighupRequested?: (handler: () => void) => void,
269286 * identityPersistedPath?: string,
287+ * localConfigPath?: string,
288+ * pidFilePath?: string,
270289 * }} [hooks]
290+ * `localConfigPath` (when set) enables SIGHUP-driven re-read of the local
291+ * config file — the standard Unix "reload without restart" pattern. Used
292+ * by `ctvs gascity attach/detach` to push a config edit live without
293+ * bouncing the whole daemon.
294+ * `pidFilePath` overrides where the daemon writes its PID; the default is
295+ * `~/.collectivus/runtime/collectivus.pid`. The CLI reads it to find a
296+ * live daemon for SIGHUP.
271297 * @returns {Promise<number> }
272298 */
273299export async function runWithConfig ( config , env , hooks = { } ) {
@@ -354,7 +380,31 @@ export async function runWithConfig(config, env, hooks = {}) {
354380 const hotReload = configClient
355381 ? { initialConfig : config , configClient, factoryBuilder }
356382 : undefined
357- return runLifecycle ( factoryBuilder ( config ) , stdout , stderr , onShutdownRequested , hotReload )
383+
384+ /** @type {Parameters<typeof runLifecycle>[5] } */
385+ const extras = { }
386+ if ( hooks . localConfigPath !== undefined && ! hotReload ) {
387+ const { localConfigPath } = hooks
388+ /** @type {LocalReloadWiring } */
389+ const localReload = {
390+ initialConfig : config ,
391+ factoryBuilder,
392+ reload : async ( ) => {
393+ const reloaded = await loadConfigAsync ( localConfigPath , { stderr } )
394+ return resolveRuntimeSecrets ( reloaded , env ?? { } )
395+ } ,
396+ }
397+ extras . localReload = localReload
398+ if ( hooks . onSighupRequested !== undefined ) extras . onSighupRequested = hooks . onSighupRequested
399+ }
400+ // Only the standalone daemon writes a PID file. Gateway/server roles
401+ // typically run under a supervisor that already tracks PIDs (launchd,
402+ // systemd, k8s) and an out-of-band PID file would just go stale.
403+ if ( config . role !== 'gateway' && config . role !== 'server' ) {
404+ extras . pidFile = { path : hooks . pidFilePath ?? defaultPidFilePath ( ) }
405+ }
406+
407+ return runLifecycle ( factoryBuilder ( config ) , stdout , stderr , onShutdownRequested , hotReload , extras )
358408}
359409
360410/**
@@ -670,9 +720,24 @@ function buildSelfUpdateFactory(ctx) {
670720 * @param {HotReloadWiring } [hotReload] When supplied, subscribe to
671721 * `configClient.on('config-changed')` and route each event through
672722 * `applyDiff`, mutating the running registry in place.
723+ * @param {{
724+ * localReload?: LocalReloadWiring,
725+ * pidFile?: { path: string },
726+ * onSighupRequested?: (handler: () => void) => void,
727+ * }} [extras]
728+ * `localReload` enables SIGHUP-driven config reread for the standalone
729+ * daemon (used by `ctvs gascity attach/detach`). The handler re-reads the
730+ * config from disk via the supplied callback, then runs the same diff/apply
731+ * chain as the gateway hot-reload path. Gascity is special-cased to mutate
732+ * its existing listener in place via `applyCityDiff` rather than tearing
733+ * the whole source down.
734+ * `pidFile` writes the daemon's PID at startup and removes it on shutdown
735+ * so the CLI can find a live daemon for SIGHUP.
736+ * `onSighupRequested` is overridable for tests; production wires
737+ * `process.on('SIGHUP')`.
673738 * @returns {Promise<number> }
674739 */
675- async function runLifecycle ( factories , stdout , stderr , onShutdownRequested , hotReload ) {
740+ async function runLifecycle ( factories , stdout , stderr , onShutdownRequested , hotReload , extras = { } ) {
676741 if ( factories . size === 0 ) {
677742 stderr . write ( 'error: no listeners configured\n' )
678743 return 1
@@ -701,35 +766,192 @@ async function runLifecycle(factories, stdout, stderr, onShutdownRequested, hotR
701766 }
702767 }
703768
769+ if ( extras . pidFile ) {
770+ try {
771+ await writePidFile ( extras . pidFile . path )
772+ } catch ( err ) {
773+ stderr . write ( `warning: failed to write pid file ${ extras . pidFile . path } : ${ formatError ( err ) } \n` )
774+ }
775+ }
776+
704777 // Serialize hot-reload applications onto a single chain so concurrent
705778 // `'config-changed'` emits (in practice the ConfigClient ticks
706779 // sequentially, but defensive serialization keeps the invariant local)
707780 // can't interleave their stop/start operations and leak a listener.
708781 /** @type {Promise<void> } */
709782 let reloadChain = Promise . resolve ( )
783+ /** @type {CollectivusConfig | undefined } */
784+ let currentCfg = hotReload ? hotReload . initialConfig : extras . localReload ?. initialConfig
710785 if ( hotReload ) {
711- let currentCfg = hotReload . initialConfig
712786 hotReload . configClient . on ( 'config-changed' , ( /** @type {ConfigChangedEvent } */ event ) => {
713787 reloadChain = reloadChain . then ( async ( ) => {
714788 const newCfg = event . newConfig
789+ if ( currentCfg === undefined ) currentCfg = newCfg
715790 const diff = diffConfig ( currentCfg , newCfg )
716791 await applyDiff ( diff , currentCfg , newCfg , started , hotReload . factoryBuilder , { stdout, stderr } )
792+ await applyGascitySectionDiff ( currentCfg , newCfg , started , hotReload . factoryBuilder , { stdout, stderr } )
717793 currentCfg = newCfg
718794 } ) . catch ( ( err ) => {
719795 stderr . write ( `hot reload: unexpected error: ${ formatError ( err ) } \n` )
720796 } )
721797 } )
722798 }
723799
800+ if ( extras . localReload ) {
801+ const { localReload } = extras
802+ const onSighupRequested = extras . onSighupRequested ?? defaultSighupWiring
803+ onSighupRequested ( ( ) => {
804+ reloadChain = reloadChain . then ( async ( ) => {
805+ /** @type {CollectivusConfig } */
806+ let newCfg
807+ try {
808+ newCfg = await localReload . reload ( )
809+ } catch ( err ) {
810+ stderr . write ( `local reload: failed to re-read config: ${ formatError ( err ) } \n` )
811+ return
812+ }
813+ if ( currentCfg === undefined ) currentCfg = newCfg
814+ stdout . write ( 'local reload: applying config\n' )
815+ const diff = diffConfig ( currentCfg , newCfg )
816+ await applyDiff ( diff , currentCfg , newCfg , started , localReload . factoryBuilder , { stdout, stderr } )
817+ await applyGascitySectionDiff ( currentCfg , newCfg , started , localReload . factoryBuilder , { stdout, stderr } )
818+ currentCfg = newCfg
819+ } ) . catch ( ( err ) => {
820+ stderr . write ( `local reload: unexpected error: ${ formatError ( err ) } \n` )
821+ } )
822+ } )
823+ }
824+
724825 await shutdownPromise
725826 // Drain any in-flight reload so its stop() lands before stopAll() races
726827 // it. The chain only does start/stop work, bounded and short.
727828 await reloadChain
728829 await stopAll ( started , stderr )
830+ if ( extras . pidFile ) {
831+ try {
832+ await removePidFile ( extras . pidFile . path )
833+ } catch ( err ) {
834+ stderr . write ( `warning: failed to remove pid file ${ extras . pidFile . path } : ${ formatError ( err ) } \n` )
835+ }
836+ }
729837 stdout . write ( 'Shutdown complete.\n' )
730838 return 0
731839}
732840
841+ /**
842+ * Reload-time gascity diff.
843+ *
844+ * The standard `applyDiff` only handles otel/proxy/sink/upload — those
845+ * sections share a "stop the old, start the new" pattern. The gascity
846+ * source is different: each city carries an SSE connection plus a set of
847+ * active session workers, and we don't want a config edit that touches
848+ * one city (e.g. `ctvs gascity attach city2`) to retire the live workers
849+ * for unchanged cities. Instead we mutate the listener in place by
850+ * calling `applyCityDiff(newCities)` on the existing instance.
851+ *
852+ * Three transitions to handle:
853+ * - oldGascity defined, newGascity defined → applyCityDiff
854+ * - oldGascity undefined, newGascity defined → spin up via factory
855+ * - oldGascity defined, newGascity undefined → stop and remove
856+ *
857+ * @param {CollectivusConfig } oldCfg
858+ * @param {CollectivusConfig } newCfg
859+ * @param {Map<string, StartedListener> } registry
860+ * @param {(cfg: CollectivusConfig) => Map<string, ListenerFactory> } factoryBuilder
861+ * @param {{ stdout: { write(s: string): void }, stderr: { write(s: string): void } } } log
862+ * @returns {Promise<void> }
863+ */
864+ async function applyGascitySectionDiff ( oldCfg , newCfg , registry , factoryBuilder , log ) {
865+ const oldGascity = oldCfg . gascity
866+ const newGascity = newCfg . gascity
867+ if ( oldGascity === undefined && newGascity === undefined ) return
868+ if ( gascityArraysEqual ( oldGascity ?? [ ] , newGascity ?? [ ] ) ) return
869+ const existing = /** @type {(import('./types.js').StartedListener & { applyCityDiff?: (c: import('./gascity/types.d.ts').GascityCityConfig[]) => Promise<void> }) | undefined } */ (
870+ registry . get ( 'gascity' )
871+ )
872+
873+ if ( existing && newGascity !== undefined && typeof existing . applyCityDiff === 'function' ) {
874+ try {
875+ await existing . applyCityDiff ( newGascity )
876+ log . stdout . write ( `local reload: gascity diff applied (${ newGascity . length } ${ newGascity . length === 1 ? 'city' : 'cities' } )\n` )
877+ } catch ( err ) {
878+ log . stderr . write ( `local reload: gascity applyCityDiff failed: ${ formatError ( err ) } \n` )
879+ }
880+ return
881+ }
882+ if ( ! existing && newGascity !== undefined ) {
883+ const factory = factoryBuilder ( newCfg ) . get ( 'gascity' )
884+ if ( ! factory ) return
885+ try {
886+ const listener = await factory ( )
887+ registry . set ( 'gascity' , listener )
888+ log . stdout . write ( `local reload: gascity started — ${ listener . description } \n` )
889+ } catch ( err ) {
890+ log . stderr . write ( `local reload: failed to start gascity: ${ formatError ( err ) } \n` )
891+ }
892+ return
893+ }
894+ if ( existing && newGascity === undefined ) {
895+ try {
896+ await existing . stop ( )
897+ registry . delete ( 'gascity' )
898+ log . stdout . write ( 'local reload: gascity stopped\n' )
899+ } catch ( err ) {
900+ log . stderr . write ( `local reload: failed to stop gascity: ${ formatError ( err ) } \n` )
901+ }
902+ }
903+ }
904+
905+ /**
906+ * Deep equality on two `gascity` arrays. Order matters because `[[gascity]]`
907+ * entries are compared positionally — a swap is a config-level change
908+ * intentionally, even when the set of names is identical.
909+ *
910+ * @param {readonly import('./gascity/types.d.ts').GascityCityConfig[] } a
911+ * @param {readonly import('./gascity/types.d.ts').GascityCityConfig[] } b
912+ * @returns {boolean }
913+ */
914+ function gascityArraysEqual ( a , b ) {
915+ if ( a . length !== b . length ) return false
916+ for ( let i = 0 ; i < a . length ; i ++ ) {
917+ const ca = a [ i ]
918+ const cb = b [ i ]
919+ if ( ca . name !== cb . name ) return false
920+ if ( ca . api_url !== cb . api_url ) return false
921+ if ( ! stringListEqual ( ca . include_templates , cb . include_templates ) ) return false
922+ if ( ! stringListEqual ( ca . exclude_templates , cb . exclude_templates ) ) return false
923+ }
924+ return true
925+ }
926+
927+ /**
928+ * @param {string[] | undefined } a
929+ * @param {string[] | undefined } b
930+ * @returns {boolean }
931+ */
932+ function stringListEqual ( a , b ) {
933+ if ( a === undefined && b === undefined ) return true
934+ if ( a === undefined || b === undefined ) return false
935+ if ( a . length !== b . length ) return false
936+ for ( let i = 0 ; i < a . length ; i ++ ) {
937+ if ( a [ i ] !== b [ i ] ) return false
938+ }
939+ return true
940+ }
941+
942+ /**
943+ * Wire SIGHUP to the supplied handler. SIGHUP is the standard Unix signal
944+ * for "re-read config without restarting" — using it here means the
945+ * `ctvs gascity attach/detach` CLI just sends one signal and the daemon
946+ * handles the rest.
947+ *
948+ * @param {() => void } handler
949+ * @returns {void }
950+ */
951+ function defaultSighupWiring ( handler ) {
952+ process . on ( 'SIGHUP' , handler )
953+ }
954+
733955/**
734956 * @param {Map<string, StartedListener> } started
735957 * @param {{ write: (s: string) => void } } stderr
0 commit comments