switchroom 0.15.6 → 0.15.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11232,6 +11232,10 @@ var ReactionsSchema = exports_external.object({
11232
11232
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
11233
11233
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag — the reacter IS the user. " + "Default true.")
11234
11234
  }).optional();
11235
+ var ReactionDispatchSchema = exports_external.object({
11236
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
11237
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
11238
+ }).optional();
11235
11239
  var ReleaseBlock = exports_external.object({
11236
11240
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
11237
11241
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -11267,6 +11271,7 @@ var profileFields = {
11267
11271
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
11268
11272
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker — independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 — 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
11269
11273
  reactions: ReactionsSchema,
11274
+ reaction_dispatch: ReactionDispatchSchema,
11270
11275
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
11271
11276
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11272
11277
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
@@ -11336,6 +11341,7 @@ var AgentSchema = exports_external.object({
11336
11341
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
11337
11342
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
11338
11343
  reactions: ReactionsSchema,
11344
+ reaction_dispatch: ReactionDispatchSchema,
11339
11345
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
11340
11346
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11341
11347
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
@@ -12001,6 +12007,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
12001
12007
  }
12002
12008
  merged.reactions = combined;
12003
12009
  }
12010
+ const dReactionDispatch = defaults.reaction_dispatch;
12011
+ const mReactionDispatch = merged.reaction_dispatch;
12012
+ if (dReactionDispatch || mReactionDispatch) {
12013
+ const base = dReactionDispatch ?? {};
12014
+ const override = mReactionDispatch ?? {};
12015
+ const combined = { ...base };
12016
+ for (const [k, v] of Object.entries(override)) {
12017
+ if (v !== undefined)
12018
+ combined[k] = v;
12019
+ }
12020
+ merged.reaction_dispatch = combined;
12021
+ }
12004
12022
  if (defaults.resources || merged.resources) {
12005
12023
  const d = defaults.resources ?? {};
12006
12024
  const a = merged.resources ?? {};
@@ -14364,6 +14382,43 @@ function registerAgentSchedule(opts) {
14364
14382
  }
14365
14383
  return tasks;
14366
14384
  }
14385
+ function scheduleSignature(entries) {
14386
+ return JSON.stringify(entries);
14387
+ }
14388
+ function createScheduleReloader(opts) {
14389
+ let tasks = opts.initialTasks;
14390
+ let sig = scheduleSignature(opts.initialEntries);
14391
+ return {
14392
+ tick() {
14393
+ let next;
14394
+ try {
14395
+ next = opts.loadEntries();
14396
+ } catch (err) {
14397
+ opts.onError?.(err);
14398
+ return;
14399
+ }
14400
+ const nextSig = scheduleSignature(next);
14401
+ if (nextSig === sig)
14402
+ return;
14403
+ const before = tasks.length;
14404
+ for (const t of tasks)
14405
+ t.task.stop();
14406
+ tasks = opts.register(next);
14407
+ sig = nextSig;
14408
+ opts.log(`schedule reloaded: ${before} → ${tasks.length} task(s)`);
14409
+ },
14410
+ currentTasks() {
14411
+ return tasks;
14412
+ }
14413
+ };
14414
+ }
14415
+ function resolveReloadPollMs(env) {
14416
+ const raw = Number.parseInt(env.SWITCHROOM_SCHEDULER_RELOAD_POLL_MS ?? "", 10);
14417
+ return Number.isFinite(raw) && raw >= 1000 ? raw : 30000;
14418
+ }
14419
+ function isHotReloadEnabled(env) {
14420
+ return env.SWITCHROOM_SCHEDULER_HOT_RELOAD !== "0";
14421
+ }
14367
14422
  function ipcDispatcher(client) {
14368
14423
  return {
14369
14424
  sendToAgent(agentName, inbound) {
@@ -14405,8 +14460,28 @@ async function main() {
14405
14460
  if (entries.length === 0) {
14406
14461
  process.stdout.write(`agent-scheduler: ${agentName} has no schedule entries — idling ` + `(re-checks on container restart)
14407
14462
  `);
14408
- setInterval(() => {}, 1 << 30);
14463
+ let idleTimer;
14464
+ if (isHotReloadEnabled(process.env)) {
14465
+ idleTimer = setInterval(() => {
14466
+ let appeared;
14467
+ try {
14468
+ appeared = collectScheduleEntries(loadConfig(configPath)).filter((e) => e.agent === agentName);
14469
+ } catch {
14470
+ return;
14471
+ }
14472
+ if (appeared.length > 0) {
14473
+ process.stdout.write(`agent-scheduler: ${agentName} schedule appeared (${appeared.length} ` + `entr${appeared.length === 1 ? "y" : "ies"}) — restarting to activate
14474
+ `);
14475
+ clearInterval(idleTimer);
14476
+ releaseLock(lockPath);
14477
+ process.exit(0);
14478
+ }
14479
+ }, resolveReloadPollMs(process.env));
14480
+ } else {
14481
+ idleTimer = setInterval(() => {}, 1 << 30);
14482
+ }
14409
14483
  const cleanup = () => {
14484
+ clearInterval(idleTimer);
14410
14485
  releaseLock(lockPath);
14411
14486
  process.exit(0);
14412
14487
  };
@@ -14551,8 +14626,8 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
14551
14626
  process.stdout.write(`agent-scheduler: ${agentName} cheap-cron ENABLED` + (recovered > 0 ? ` (recovered ${recovered} pending escalation(s))` : "") + `
14552
14627
  `);
14553
14628
  }
14554
- const tasks = registerAgentSchedule({
14555
- entries,
14629
+ const registerForEntries = (es) => registerAgentSchedule({
14630
+ entries: es,
14556
14631
  channel,
14557
14632
  sink,
14558
14633
  cronLib,
@@ -14560,10 +14635,28 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
14560
14635
  ...quotaGate ? { quotaGate } : {},
14561
14636
  ...cheapCron ? { cheapCron } : {}
14562
14637
  });
14638
+ const tasks = registerForEntries(entries);
14563
14639
  process.stdout.write(`agent-scheduler: ${agentName} registered ${tasks.length} task(s); ` + `chat=${channel.chatId} thread=${channel.threadId ?? "(none)"} ` + `socket=${socketPath} jsonl=${jsonlPath}
14564
14640
  `);
14641
+ let reloader;
14642
+ let reloadTimer;
14643
+ if (isHotReloadEnabled(process.env)) {
14644
+ reloader = createScheduleReloader({
14645
+ loadEntries: () => collectScheduleEntries(loadConfig(configPath)).filter((e) => e.agent === agentName),
14646
+ register: registerForEntries,
14647
+ initialTasks: tasks,
14648
+ initialEntries: entries,
14649
+ log: (m) => process.stdout.write(`agent-scheduler: ${agentName} ${m}
14650
+ `),
14651
+ onError: (e) => process.stderr.write(`agent-scheduler: ${agentName} reload skipped (config error, keeping current schedule): ${e.message}
14652
+ `)
14653
+ });
14654
+ reloadTimer = setInterval(() => reloader.tick(), resolveReloadPollMs(process.env));
14655
+ }
14565
14656
  const shutdown = () => {
14566
- for (const t of tasks)
14657
+ if (reloadTimer)
14658
+ clearInterval(reloadTimer);
14659
+ for (const t of reloader ? reloader.currentTasks() : tasks)
14567
14660
  t.task.stop();
14568
14661
  sink.close();
14569
14662
  ipcClient.close();
@@ -14581,10 +14674,14 @@ if (import.meta.url === `file://${process.argv[1]}` && /(?:^|[/\\])agent-schedul
14581
14674
  });
14582
14675
  }
14583
14676
  export {
14677
+ scheduleSignature,
14678
+ resolveReloadPollMs,
14584
14679
  resolveEntryThreadId,
14585
14680
  resolveChannelTarget,
14586
14681
  registerAgentSchedule,
14587
14682
  recoverPendingEscalations,
14588
14683
  main,
14589
- ipcDispatcher
14684
+ isHotReloadEnabled,
14685
+ ipcDispatcher,
14686
+ createScheduleReloader
14590
14687
  };
@@ -11232,6 +11232,10 @@ var ReactionsSchema = exports_external.object({
11232
11232
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
11233
11233
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag — the reacter IS the user. " + "Default true.")
11234
11234
  }).optional();
11235
+ var ReactionDispatchSchema = exports_external.object({
11236
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
11237
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
11238
+ }).optional();
11235
11239
  var ReleaseBlock = exports_external.object({
11236
11240
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
11237
11241
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -11267,6 +11271,7 @@ var profileFields = {
11267
11271
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
11268
11272
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker — independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 — 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
11269
11273
  reactions: ReactionsSchema,
11274
+ reaction_dispatch: ReactionDispatchSchema,
11270
11275
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
11271
11276
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11272
11277
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
@@ -11336,6 +11341,7 @@ var AgentSchema = exports_external.object({
11336
11341
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
11337
11342
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
11338
11343
  reactions: ReactionsSchema,
11344
+ reaction_dispatch: ReactionDispatchSchema,
11339
11345
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
11340
11346
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11341
11347
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
@@ -12001,6 +12007,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
12001
12007
  }
12002
12008
  merged.reactions = combined;
12003
12009
  }
12010
+ const dReactionDispatch = defaults.reaction_dispatch;
12011
+ const mReactionDispatch = merged.reaction_dispatch;
12012
+ if (dReactionDispatch || mReactionDispatch) {
12013
+ const base = dReactionDispatch ?? {};
12014
+ const override = mReactionDispatch ?? {};
12015
+ const combined = { ...base };
12016
+ for (const [k, v] of Object.entries(override)) {
12017
+ if (v !== undefined)
12018
+ combined[k] = v;
12019
+ }
12020
+ merged.reaction_dispatch = combined;
12021
+ }
12004
12022
  if (defaults.resources || merged.resources) {
12005
12023
  const d = defaults.resources ?? {};
12006
12024
  const a = merged.resources ?? {};
@@ -11980,6 +11980,10 @@ var ReactionsSchema = exports_external.object({
11980
11980
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
11981
11981
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag \u2014 the reacter IS the user. " + "Default true.")
11982
11982
  }).optional();
11983
+ var ReactionDispatchSchema = exports_external.object({
11984
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false \u2014 " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
11985
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) \u2014 a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
11986
+ }).optional();
11983
11987
  var ReleaseBlock = exports_external.object({
11984
11988
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
11985
11989
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -12015,6 +12019,7 @@ var profileFields = {
12015
12019
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
12016
12020
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker \u2014 independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 \u2014 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
12017
12021
  reactions: ReactionsSchema,
12022
+ reaction_dispatch: ReactionDispatchSchema,
12018
12023
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
12019
12024
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
12020
12025
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -12084,6 +12089,7 @@ var AgentSchema = exports_external.object({
12084
12089
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
12085
12090
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
12086
12091
  reactions: ReactionsSchema,
12092
+ reaction_dispatch: ReactionDispatchSchema,
12087
12093
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
12088
12094
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
12089
12095
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -12751,6 +12757,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
12751
12757
  }
12752
12758
  merged.reactions = combined;
12753
12759
  }
12760
+ const dReactionDispatch = defaults.reaction_dispatch;
12761
+ const mReactionDispatch = merged.reaction_dispatch;
12762
+ if (dReactionDispatch || mReactionDispatch) {
12763
+ const base = dReactionDispatch ?? {};
12764
+ const override = mReactionDispatch ?? {};
12765
+ const combined = { ...base };
12766
+ for (const [k, v] of Object.entries(override)) {
12767
+ if (v !== undefined)
12768
+ combined[k] = v;
12769
+ }
12770
+ merged.reaction_dispatch = combined;
12771
+ }
12754
12772
  if (defaults.resources || merged.resources) {
12755
12773
  const d = defaults.resources ?? {};
12756
12774
  const a = merged.resources ?? {};
@@ -13520,7 +13520,7 @@ var init_zod = __esm(() => {
13520
13520
  });
13521
13521
 
13522
13522
  // src/config/schema.ts
13523
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
13523
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
13524
13524
  var init_schema = __esm(() => {
13525
13525
  init_zod();
13526
13526
  CodeRepoEntrySchema = exports_external.object({
@@ -13796,6 +13796,10 @@ var init_schema = __esm(() => {
13796
13796
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
13797
13797
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag \u2014 the reacter IS the user. " + "Default true.")
13798
13798
  }).optional();
13799
+ ReactionDispatchSchema = exports_external.object({
13800
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false \u2014 " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
13801
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) \u2014 a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
13802
+ }).optional();
13799
13803
  ReleaseBlock = exports_external.object({
13800
13804
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
13801
13805
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -13831,6 +13835,7 @@ var init_schema = __esm(() => {
13831
13835
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
13832
13836
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker \u2014 independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 \u2014 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
13833
13837
  reactions: ReactionsSchema,
13838
+ reaction_dispatch: ReactionDispatchSchema,
13834
13839
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
13835
13840
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
13836
13841
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -13900,6 +13905,7 @@ var init_schema = __esm(() => {
13900
13905
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
13901
13906
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
13902
13907
  reactions: ReactionsSchema,
13908
+ reaction_dispatch: ReactionDispatchSchema,
13903
13909
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
13904
13910
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
13905
13911
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -14617,6 +14623,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
14617
14623
  }
14618
14624
  merged.reactions = combined;
14619
14625
  }
14626
+ const dReactionDispatch = defaults.reaction_dispatch;
14627
+ const mReactionDispatch = merged.reaction_dispatch;
14628
+ if (dReactionDispatch || mReactionDispatch) {
14629
+ const base = dReactionDispatch ?? {};
14630
+ const override = mReactionDispatch ?? {};
14631
+ const combined = { ...base };
14632
+ for (const [k, v] of Object.entries(override)) {
14633
+ if (v !== undefined)
14634
+ combined[k] = v;
14635
+ }
14636
+ merged.reaction_dispatch = combined;
14637
+ }
14620
14638
  if (defaults.resources || merged.resources) {
14621
14639
  const d = defaults.resources ?? {};
14622
14640
  const a = merged.resources ?? {};
@@ -49409,6 +49427,10 @@ function dispatchTool(name, args) {
49409
49427
  base.push("--name", a.name);
49410
49428
  if (a.secrets && a.secrets.length > 0)
49411
49429
  base.push("--secrets", a.secrets.join(","));
49430
+ if (a.model)
49431
+ base.push("--model", a.model);
49432
+ if (a.context)
49433
+ base.push("--context", a.context);
49412
49434
  cliArgs = base;
49413
49435
  parseMode = "json";
49414
49436
  break;
@@ -49593,7 +49615,7 @@ var init_server3 = __esm(() => {
49593
49615
  },
49594
49616
  {
49595
49617
  name: "schedule_add",
49596
- description: "Append a cron schedule entry to the agent's overlay dir. " + "Overlay-sourced entries with non-empty `secrets:` are REJECTED " + "(E_OVERLAY_SECRETS_REQUIRES_APPROVAL); operator-authored entries " + "in switchroom.yaml are unaffected.",
49618
+ description: "Append a cron schedule entry to your overlay. Takes effect within ~30s \u2014 " + "the scheduler hot-reloads, no restart needed. Overlay entries with " + "non-empty `secrets:` are REJECTED (E_OVERLAY_SECRETS_REQUIRES_APPROVAL). " + "COST: by default each fire runs as a full turn in your live session " + "(your model, your whole context) \u2014 fine for work that needs your memory/" + "persona, but costly for routine checks. For a lighter recurring task, set " + '`model: "sonnet"` to run that fire in a cheap, minimal-context cron ' + "session instead (saves tokens; needs cheap-cron enabled by the operator). " + "For 'only do something when X changes' (e.g. a webpage, or a reaction), " + "ask the operator to set up a poll or reaction-dispatch instead of a " + "frequent prompt cron \u2014 far cheaper than polling with a full turn.",
49597
49619
  inputSchema: {
49598
49620
  type: "object",
49599
49621
  required: ["cron_expr", "prompt"],
@@ -49601,7 +49623,16 @@ var init_server3 = __esm(() => {
49601
49623
  cron_expr: { type: "string" },
49602
49624
  prompt: { type: "string", minLength: 1, maxLength: 4000 },
49603
49625
  secrets: { type: "array", items: { type: "string" } },
49604
- name: { type: "string", pattern: "^[a-z0-9-]{1,40}$" }
49626
+ name: { type: "string", pattern: "^[a-z0-9-]{1,40}$" },
49627
+ model: {
49628
+ type: "string",
49629
+ description: "Optional cheap-cron tier hint. A known-cheap model ('sonnet'/'haiku') " + "routes this fire to a fresh, minimal-context cron session (Tier 1) " + "instead of your full live session (Tier 2) \u2014 cheaper per fire. Omit " + "for context-heavy work that needs your memory/persona. Inert unless " + "the operator has enabled cheap-cron."
49630
+ },
49631
+ context: {
49632
+ type: "string",
49633
+ enum: ["fresh", "agent"],
49634
+ description: "Tier hint: 'fresh' = minimal-context cheap session (Tier 1); 'agent' " + "= your full live session (Tier 2). Usually inferred from `model`."
49635
+ }
49605
49636
  }
49606
49637
  }
49607
49638
  },
@@ -50204,8 +50235,8 @@ var {
50204
50235
  } = import__.default;
50205
50236
 
50206
50237
  // src/build-info.ts
50207
- var VERSION = "0.15.6";
50208
- var COMMIT_SHA = "3ae297b9";
50238
+ var VERSION = "0.15.8";
50239
+ var COMMIT_SHA = "318cb85f";
50209
50240
 
50210
50241
  // src/cli/agent.ts
50211
50242
  init_source();
@@ -50409,6 +50440,13 @@ function stripSecretValues(value) {
50409
50440
  function restartRequiredNote(agent) {
50410
50441
  return `Not live yet \u2014 claude loads skills, MCP servers and scheduled ` + `tasks at process start. Run \`switchroom agent restart ${agent}\` ` + `for this to take effect.`;
50411
50442
  }
50443
+ function scheduleRestartRequired() {
50444
+ return (process.env.SWITCHROOM_SCHEDULER_HOT_RELOAD ?? "") === "0";
50445
+ }
50446
+ function scheduleLiveNote(agent) {
50447
+ const hotReload = (process.env.SWITCHROOM_SCHEDULER_HOT_RELOAD ?? "") !== "0";
50448
+ return hotReload ? `Live within ~30s \u2014 the in-agent scheduler hot-reloads the overlay; no restart needed.` : `Not live yet \u2014 hot-reload is disabled (SWITCHROOM_SCHEDULER_HOT_RELOAD=0). ` + `Run \`switchroom agent restart ${agent}\` for this to take effect.`;
50449
+ }
50412
50450
  function getAgentSlice(config, agent) {
50413
50451
  const slice = config.agents?.[agent];
50414
50452
  if (!slice) {
@@ -68118,6 +68156,7 @@ init_source();
68118
68156
  // src/web/server.ts
68119
68157
  init_merge();
68120
68158
  init_loader();
68159
+ init_client();
68121
68160
  init_lifecycle();
68122
68161
  import {
68123
68162
  readFileSync as readFileSync45,
@@ -74490,12 +74529,37 @@ function loadWebhookSecrets() {
74490
74529
  return {};
74491
74530
  }
74492
74531
  }
74532
+ async function resolveWebhookSecretFromVault(agent, source, config) {
74533
+ const key = `webhook/${agent}/${source}`;
74534
+ try {
74535
+ const socket = resolveBrokerSocketPath({
74536
+ vaultBrokerSocket: config.vault?.broker?.socket ? resolvePath(config.vault.broker.socket) : undefined
74537
+ });
74538
+ const result = await getViaBrokerStructured(key, { socket });
74539
+ if (result.kind === "ok" && result.entry.kind === "string") {
74540
+ return result.entry.value;
74541
+ }
74542
+ if (result.kind === "denied" || result.kind === "unreachable") {
74543
+ process.stderr.write(`webhook-ingest: vault resolve for ${key} \u2192 ${result.kind}` + `${"code" in result && result.code ? ` (${result.code})` : ""}` + `; webhook will 401 until the secret is operator-readable
74544
+ `);
74545
+ }
74546
+ } catch (err) {
74547
+ process.stderr.write(`webhook-ingest: vault resolve for ${key} threw: ${err.message}
74548
+ `);
74549
+ }
74550
+ return null;
74551
+ }
74493
74552
  async function handleWebhookRoute(req, agent, source, config) {
74494
74553
  const agentConfigRaw = config.agents[agent];
74495
74554
  const agentConfig = agentConfigRaw ? resolveAgentConfig(config.defaults, config.profiles, agentConfigRaw) : undefined;
74496
74555
  const allowedSources = agentConfig?.channels?.telegram?.webhook_sources ?? [];
74497
74556
  const allSecrets = loadWebhookSecrets();
74498
- const agentSecrets = allSecrets[agent] ?? {};
74557
+ const agentSecrets = { ...allSecrets[agent] ?? {} };
74558
+ if (!agentSecrets[source]) {
74559
+ const fromVault = await resolveWebhookSecretFromVault(agent, source, config);
74560
+ if (fromVault)
74561
+ agentSecrets[source] = fromVault;
74562
+ }
74499
74563
  const requireEdge = agentConfig?.channels?.telegram?.webhook_require_edge === true;
74500
74564
  const edgeSecret = requireEdge ? loadEdgeSecret() : null;
74501
74565
  let bodyBuf;
@@ -82933,6 +82997,10 @@ function scheduleAdd(opts) {
82933
82997
  entry.secrets = opts.secrets;
82934
82998
  if (opts.name)
82935
82999
  entry.name = opts.name;
83000
+ if (opts.model)
83001
+ entry.model = opts.model;
83002
+ if (opts.context)
83003
+ entry.context = opts.context;
82936
83004
  const doc = {
82937
83005
  schedule: [
82938
83006
  Object.fromEntries(Object.entries(entry).filter(([k]) => k !== "name"))
@@ -83017,8 +83085,8 @@ function scheduleAdd(opts) {
83017
83085
  path: path8,
83018
83086
  cron_hash: hash2,
83019
83087
  would_recreate: false,
83020
- restart_required: true,
83021
- restart_hint: restartRequiredNote(agent)
83088
+ restart_required: scheduleRestartRequired(),
83089
+ restart_hint: scheduleLiveNote(agent)
83022
83090
  };
83023
83091
  }
83024
83092
  function scheduleAddOrStage(opts) {
@@ -83043,6 +83111,10 @@ function scheduleAddOrStage(opts) {
83043
83111
  entry.secrets = opts.secrets;
83044
83112
  if (opts.name)
83045
83113
  entry.name = opts.name;
83114
+ if (opts.model)
83115
+ entry.model = opts.model;
83116
+ if (opts.context)
83117
+ entry.context = opts.context;
83046
83118
  const doc = {
83047
83119
  schedule: [
83048
83120
  Object.fromEntries(Object.entries(entry).filter(([k]) => k !== "name"))
@@ -83142,13 +83214,17 @@ function scheduleRemove(opts) {
83142
83214
  ok: true,
83143
83215
  slug: match.slug,
83144
83216
  path: match.path,
83145
- restart_required: true,
83146
- restart_hint: restartRequiredNote(agent)
83217
+ restart_required: scheduleRestartRequired(),
83218
+ restart_hint: scheduleLiveNote(agent)
83147
83219
  };
83148
83220
  }
83149
83221
  function registerAgentConfigWriteCommands(program3) {
83150
83222
  const schedule = program3.command("schedule").description("Add / remove an agent's scheduled cron entries (overlay-backed)");
83151
- schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--stage-on-reject", "When a security gate trips (secrets/quota/min-interval), stage the entry under .pending/ for operator approval instead of rejecting with exit 9. Used by the MCP path; operator CLI defaults to off.").action(async (opts) => {
83223
+ schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--model <id>", "Cheap-cron tier hint: a known-cheap model (sonnet/haiku) routes this fire to a fresh, minimal-context cron session (Tier 1) instead of the agent's full live session (Tier 2), cutting token cost. Inert unless SWITCHROOM_CHEAP_CRON is on.").option("--context <mode>", "Tier hint: 'fresh' (minimal-context cheap session) or 'agent' (full live session). Unset \u2192 inferred from --model.").option("--stage-on-reject", "When a security gate trips (secrets/quota/min-interval), stage the entry under .pending/ for operator approval instead of rejecting with exit 9. Used by the MCP path; operator CLI defaults to off.").action(async (opts) => {
83224
+ if (opts.context && opts.context !== "fresh" && opts.context !== "agent") {
83225
+ emitError("E_INVALID_PROMPT", "--context must be 'fresh' or 'agent'");
83226
+ process.exit(1);
83227
+ }
83152
83228
  const secrets = opts.secrets ? opts.secrets.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
83153
83229
  if (opts.name && !/^[a-z0-9-]{1,40}$/.test(opts.name)) {
83154
83230
  emitError("E_INVALID_PROMPT", "name must match [a-z0-9-]{1,40}");
@@ -83166,7 +83242,9 @@ function registerAgentConfigWriteCommands(program3) {
83166
83242
  cronExpr: opts.cron,
83167
83243
  prompt: opts.prompt,
83168
83244
  secrets,
83169
- name: opts.name
83245
+ name: opts.name,
83246
+ model: opts.model,
83247
+ context: opts.context
83170
83248
  });
83171
83249
  } catch (err) {
83172
83250
  process.stderr.write(`${err.message}
@@ -85050,7 +85128,11 @@ import { homedir as homedir48 } from "node:os";
85050
85128
  import { join as join82 } from "node:path";
85051
85129
  import { spawnSync as spawnSync14 } from "node:child_process";
85052
85130
  init_audit_reader();
85053
- var DEFAULT_IMAGE_TAG = "latest";
85131
+ function resolveHostdImageTag(explicitTag, release) {
85132
+ if (explicitTag)
85133
+ return explicitTag;
85134
+ return resolveImageTag(resolveRelease({ root: release }));
85135
+ }
85054
85136
  var HOSTD_COMPOSE_PROJECT = "switchroom-hostd";
85055
85137
  function renderHostdComposeFile(opts) {
85056
85138
  const { hostHome, imageTag, operatorUid } = opts;
@@ -85150,6 +85232,16 @@ networks:
85150
85232
  # operator surface; the daemon's stderr lands in \`docker logs switchroom-hostd\`.
85151
85233
  `;
85152
85234
  }
85235
+ function resolveHostdHostHome(env2 = process.env, home2 = homedir48()) {
85236
+ const fromEnv = env2.SWITCHROOM_HOST_HOME?.trim();
85237
+ const resolved = fromEnv && fromEnv.length > 0 ? fromEnv : home2;
85238
+ if (resolved === "/host-home" || resolved.startsWith("/host-home/")) {
85239
+ throw new Error(`switchroom hostd install: refusing to generate \u2014 the host home resolved to ` + `"${resolved}", the in-container mount point of the operator home (never a valid ` + `host bind source). Emitting it would make Docker create empty /host-home dirs on ` + `the host and crash-loop hostd on a missing config mount.
85240
+
85241
+ ` + `Recovery: run \`switchroom hostd install\` from the HOST shell (not inside the ` + `hostd container), or set SWITCHROOM_HOST_HOME to the real host home first.`);
85242
+ }
85243
+ return resolved;
85244
+ }
85153
85245
  function hostdDir() {
85154
85246
  return join82(homedir48(), ".switchroom", "hostd");
85155
85247
  }
@@ -85192,9 +85284,10 @@ async function doInstall(opts, program3) {
85192
85284
  const dir = hostdDir();
85193
85285
  const composePath = hostdComposePath();
85194
85286
  mkdirSync47(dir, { recursive: true });
85287
+ const imageTag = resolveHostdImageTag(opts.tag, cfg.release);
85195
85288
  const yaml = renderHostdComposeFile({
85196
- hostHome: homedir48(),
85197
- imageTag: opts.tag ?? DEFAULT_IMAGE_TAG,
85289
+ hostHome: resolveHostdHostHome(),
85290
+ imageTag,
85198
85291
  operatorUid: resolveOperatorUid()
85199
85292
  });
85200
85293
  if (opts.dryRun) {
@@ -85211,12 +85304,12 @@ async function doInstall(opts, program3) {
85211
85304
  const adminAgents = Object.entries(cfg.agents ?? {}).filter(([, a]) => a?.admin === true).map(([name]) => name);
85212
85305
  console.log(source_default.dim(` agents served (one socket each): ${allAgents.length === 0 ? "(none)" : allAgents.join(", ")}`));
85213
85306
  console.log(source_default.dim(` admin agents (full config-edit verbs): ${adminAgents.length === 0 ? "(none)" : adminAgents.join(", ")}`));
85214
- console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-hostd:${opts.tag ?? DEFAULT_IMAGE_TAG}\u2026`));
85307
+ console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-hostd:${imageTag}\u2026`));
85215
85308
  const pull = runDocker(["compose", "-p", HOSTD_COMPOSE_PROJECT, "-f", composePath, "pull"]);
85216
85309
  if (!pull.ok) {
85217
85310
  console.error(source_default.red(` pull failed:
85218
85311
  ${pull.stderr}`));
85219
- console.error(source_default.yellow(` Hint: \`ghcr.io/switchroom/switchroom-hostd:${opts.tag ?? DEFAULT_IMAGE_TAG}\` may not be published yet.
85312
+ console.error(source_default.yellow(` Hint: \`ghcr.io/switchroom/switchroom-hostd:${imageTag}\` may not be published yet.
85220
85313
  ` + ` Check the docker-images workflow run and verify the tag at:
85221
85314
  ` + ` https://github.com/switchroom/switchroom/pkgs/container/switchroom-hostd`));
85222
85315
  process.exit(1);
@@ -85301,7 +85394,7 @@ ${down.stderr}`));
85301
85394
  }
85302
85395
  function registerHostdCommand(program3) {
85303
85396
  const hostd = program3.command("hostd").description("Manage switchroom-hostd, the host-control daemon for admin agents (RFC C)");
85304
- hostd.command("install").description("Install or refresh the hostd container (writes ~/.switchroom/hostd/docker-compose.yml + docker compose up -d)").option("--tag <tag>", "Image tag (default: latest)", DEFAULT_IMAGE_TAG).option("--dry-run", "Print the compose file and the docker commands without writing or running anything").action(withConfigError(async (opts) => {
85397
+ hostd.command("install").description("Install or refresh the hostd container (writes ~/.switchroom/hostd/docker-compose.yml + docker compose up -d)").option("--tag <tag>", "Image tag override (default: resolved from release.pin in switchroom.yaml, else latest)").option("--dry-run", "Print the compose file and the docker commands without writing or running anything").action(withConfigError(async (opts) => {
85305
85398
  await doInstall(opts, program3);
85306
85399
  }));
85307
85400
  hostd.command("status").description("Show daemon state and bound sockets").action(() => doStatus());
@@ -85359,7 +85452,11 @@ import { existsSync as existsSync85, mkdirSync as mkdirSync48, writeFileSync as
85359
85452
  import { homedir as homedir49 } from "node:os";
85360
85453
  import { join as join83 } from "node:path";
85361
85454
  import { spawnSync as spawnSync15 } from "node:child_process";
85362
- var DEFAULT_IMAGE_TAG2 = "latest";
85455
+ function resolveWebImageTag(explicitTag, release) {
85456
+ if (explicitTag)
85457
+ return explicitTag;
85458
+ return resolveImageTag(resolveRelease({ root: release }));
85459
+ }
85363
85460
  var WEB_COMPOSE_PROJECT = "switchroom-web";
85364
85461
  function renderWebComposeFile(opts) {
85365
85462
  const { hostHome, imageTag, operatorUid } = opts;
@@ -85460,7 +85557,7 @@ function runDocker2(args) {
85460
85557
  stderr: r.stderr ?? ""
85461
85558
  };
85462
85559
  }
85463
- async function doInstall2(opts) {
85560
+ async function doInstall2(opts, program3) {
85464
85561
  const operatorUid = resolveOperatorUid();
85465
85562
  if (operatorUid === undefined) {
85466
85563
  console.error(source_default.red(`Could not resolve the operator uid (no SUDO_UID and getuid() is 0 or unavailable).
@@ -85471,9 +85568,11 @@ async function doInstall2(opts) {
85471
85568
  const dir = webdDir();
85472
85569
  const composePath = webdComposePath();
85473
85570
  mkdirSync48(dir, { recursive: true });
85571
+ const cfg = getConfig(program3);
85572
+ const imageTag = resolveWebImageTag(opts.tag, cfg.release);
85474
85573
  const yaml = renderWebComposeFile({
85475
85574
  hostHome: homedir49(),
85476
- imageTag: opts.tag ?? DEFAULT_IMAGE_TAG2,
85575
+ imageTag,
85477
85576
  operatorUid
85478
85577
  });
85479
85578
  if (opts.dryRun) {
@@ -85488,12 +85587,12 @@ async function doInstall2(opts) {
85488
85587
  writeFileSync41(composePath, yaml, "utf8");
85489
85588
  console.log(source_default.green(` \u2713 Wrote ${composePath}`));
85490
85589
  console.log(source_default.dim(` running as uid ${operatorUid} (operator), network_mode: host`));
85491
- console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-web:${opts.tag ?? DEFAULT_IMAGE_TAG2}\u2026`));
85590
+ console.log(source_default.dim(` Pulling ghcr.io/switchroom/switchroom-web:${imageTag}\u2026`));
85492
85591
  const pull = runDocker2(["compose", "-p", WEB_COMPOSE_PROJECT, "-f", composePath, "pull"]);
85493
85592
  if (!pull.ok) {
85494
85593
  console.error(source_default.red(` pull failed:
85495
85594
  ${pull.stderr}`));
85496
- console.error(source_default.yellow(` Hint: \`ghcr.io/switchroom/switchroom-web:${opts.tag ?? DEFAULT_IMAGE_TAG2}\` may not be published yet.
85595
+ console.error(source_default.yellow(` Hint: \`ghcr.io/switchroom/switchroom-web:${imageTag}\` may not be published yet.
85497
85596
  ` + ` Check the docker-images workflow run and verify the tag at:
85498
85597
  ` + ` https://github.com/switchroom/switchroom/pkgs/container/switchroom-web`));
85499
85598
  process.exit(1);
@@ -85561,8 +85660,8 @@ ${down.stderr}`));
85561
85660
  }
85562
85661
  function registerWebdCommand(program3) {
85563
85662
  const webd = program3.command("webd").description("Manage switchroom-web, the dashboard + GitHub-webhook receiver container");
85564
- webd.command("install").description("Install or refresh the web container (writes ~/.switchroom/web/docker-compose.yml + docker compose up -d)").option("--tag <tag>", "Image tag (default: latest)", DEFAULT_IMAGE_TAG2).option("--dry-run", "Print the compose file and the docker commands without writing or running anything").action(withConfigError(async (opts) => {
85565
- await doInstall2(opts);
85663
+ webd.command("install").description("Install or refresh the web container (writes ~/.switchroom/web/docker-compose.yml + docker compose up -d)").option("--tag <tag>", "Image tag override (default: resolved from release.pin in switchroom.yaml, else latest)").option("--dry-run", "Print the compose file and the docker commands without writing or running anything").action(withConfigError(async (opts) => {
85664
+ await doInstall2(opts, program3);
85566
85665
  }));
85567
85666
  webd.command("status").description("Show web-service container state").action(() => doStatus2());
85568
85667
  webd.command("uninstall").description("Stop the web container. Leaves the compose file in place for re-install.").action(() => doUninstall2());