switchroom 0.13.8 → 0.13.10

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.
@@ -47314,8 +47314,8 @@ var {
47314
47314
  } = import__.default;
47315
47315
 
47316
47316
  // src/build-info.ts
47317
- var VERSION = "0.13.8";
47318
- var COMMIT_SHA = "bb713414";
47317
+ var VERSION = "0.13.10";
47318
+ var COMMIT_SHA = "e0fd6617";
47319
47319
 
47320
47320
  // src/cli/agent.ts
47321
47321
  init_source();
@@ -48766,11 +48766,13 @@ function buildWorkspaceContext(args) {
48766
48766
  There is a real person on the other end. Every turn should feel like
48767
48767
  messaging a capable colleague \u2014 not a tool emitting output. Five beats:
48768
48768
 
48769
- 1. **Acknowledge first.** On any turn that needs real work \u2014 a file
48770
- read, a search, a command \u2014 your FIRST action is a short \`reply\`
48771
- in your own voice ("on it \u2014 checking now"), before you start. Skip
48772
- it only when the whole answer is one sentence you can give straight
48773
- away.
48769
+ 1. **Acknowledge first.** Unless your whole reply is a single short
48770
+ sentence you can send right now, your FIRST action this turn is a
48771
+ brief \`reply\` in your own voice \u2014 "on it", "good question, one
48772
+ sec", "let me dig in" \u2014 before any tool call and before you
48773
+ compose the full answer. This holds even for a pure-thinking
48774
+ answer: if it will run to a paragraph, ack first. It is the line
48775
+ between a colleague and a black box.
48774
48776
  2. **Then go quiet and work.** Heads-down is correct \u2014 do NOT narrate
48775
48777
  every tool call. A typing indicator runs automatically while you
48776
48778
  work; you do not maintain it.
@@ -48780,7 +48782,10 @@ messaging a capable colleague \u2014 not a tool emitting output. Five beats:
48780
48782
  \`disable_notification: true\`.
48781
48783
  4. **Hand back delegations with synthesis.** When a sub-agent / worker
48782
48784
  returns, re-enter in YOUR voice \u2014 what it found, and what you are
48783
- doing next. Never let its raw report stand as your reply.
48785
+ doing next. Never let its raw report stand as your reply. A
48786
+ *background* worker finishes after your turn ends; its result
48787
+ arrives as a fresh \`<channel source="subagent_handback">\` turn \u2014
48788
+ treat that turn as the cue to do exactly this.
48784
48789
  5. **Deliver the answer** as a final \`reply\`.
48785
48790
 
48786
48791
  The one thing to avoid is *spam*: a reply on every tool call, on a
@@ -49485,7 +49490,7 @@ function buildSettingsHooksBlock(p) {
49485
49490
  }
49486
49491
  ] : [];
49487
49492
  const useHotReloadStable = agentConfig.channels?.telegram?.hotReloadStable === true;
49488
- const turnPacingDirective = "<turn-pacing>You are messaging a human. First action this turn: if " + "answering needs any tool call (a file read, a search, a command), " + "send a SHORT acknowledgement via the reply tool with " + "disable_notification true BEFORE the first tool call. Then work; " + "surface meaningful progress in human prose at real milestones; hand " + "back any sub-agent findings in your own voice; deliver the answer. " + "Skip the opening ack only for a one-sentence answer you can give " + "immediately.</turn-pacing>";
49493
+ const turnPacingDirective = "<turn-pacing>You are messaging a human in a chat. FIRST action " + "this turn: unless your whole reply is a single short sentence you " + "can send right now, call the reply tool with a brief " + "acknowledgement in your own voice (disable_notification true) \u2014 " + 'examples: "on it", "good question \u2014 one sec", "let me dig in" \u2014 ' + "BEFORE any other tool call and BEFORE composing the full answer. " + "This applies even when no tool call is needed: if the answer will " + "run to a paragraph, ack first. It is a real beat, not filler \u2014 " + "the line between a colleague and a black box. Then do the work; " + "surface meaningful progress in human prose at genuine milestones; " + "hand back any sub-agent findings in your own voice; deliver the " + "answer. Do not acknowledge a trivial one-liner, and never " + "acknowledge twice.</turn-pacing>";
49489
49494
  const switchroomUserPromptSubmit = [
49490
49495
  ...useHotReloadStable ? [
49491
49496
  {
@@ -49663,7 +49668,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
49663
49668
  const hindsightRecallCacheTtlSecs = agentConfig.memory?.recall?.cache_ttl_secs;
49664
49669
  const hindsightRecallMinOverlap = agentConfig.memory?.recall?.min_overlap;
49665
49670
  const startShPath = join8(agentDir, "start.sh");
49666
- {
49671
+ if (!options.skipProfileTemplates) {
49667
49672
  const basePath = getBaseProfilePath();
49668
49673
  const startShContext = {
49669
49674
  name,
@@ -49710,11 +49715,13 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
49710
49715
  There is a real person on the other end. Every turn should feel like
49711
49716
  messaging a capable colleague \u2014 not a tool emitting output. Five beats:
49712
49717
 
49713
- 1. **Acknowledge first.** On any turn that needs real work \u2014 a file
49714
- read, a search, a command \u2014 your FIRST action is a short \`reply\`
49715
- in your own voice ("on it \u2014 checking now"), before you start. Skip
49716
- it only when the whole answer is one sentence you can give straight
49717
- away.
49718
+ 1. **Acknowledge first.** Unless your whole reply is a single short
49719
+ sentence you can send right now, your FIRST action this turn is a
49720
+ brief \`reply\` in your own voice \u2014 "on it", "good question, one
49721
+ sec", "let me dig in" \u2014 before any tool call and before you
49722
+ compose the full answer. This holds even for a pure-thinking
49723
+ answer: if it will run to a paragraph, ack first. It is the line
49724
+ between a colleague and a black box.
49718
49725
  2. **Then go quiet and work.** Heads-down is correct \u2014 do NOT narrate
49719
49726
  every tool call. A typing indicator runs automatically while you
49720
49727
  work; you do not maintain it.
@@ -49724,7 +49731,10 @@ messaging a capable colleague \u2014 not a tool emitting output. Five beats:
49724
49731
  \`disable_notification: true\`.
49725
49732
  4. **Hand back delegations with synthesis.** When a sub-agent / worker
49726
49733
  returns, re-enter in YOUR voice \u2014 what it found, and what you are
49727
- doing next. Never let its raw report stand as your reply.
49734
+ doing next. Never let its raw report stand as your reply. A
49735
+ *background* worker finishes after your turn ends; its result
49736
+ arrives as a fresh \`<channel source="subagent_handback">\` turn \u2014
49737
+ treat that turn as the cue to do exactly this.
49728
49738
  5. **Deliver the answer** as a final \`reply\`.
49729
49739
 
49730
49740
  The one thing to avoid is *spam*: a reply on every tool call, on a
@@ -49797,7 +49807,7 @@ Don't wait for a slash command. Don't ask permission. Memory work is table stake
49797
49807
  changes.push(startShPath);
49798
49808
  }
49799
49809
  }
49800
- if (!options.preserveClaudeMd) {
49810
+ if (!options.preserveClaudeMd && !options.skipProfileTemplates) {
49801
49811
  const profilePath = getProfilePath(agentConfig.extends ?? DEFAULT_PROFILE);
49802
49812
  const claudeMdSrc = join8(profilePath, "CLAUDE.md.hbs");
49803
49813
  const claudeMdDest = join8(agentDir, "CLAUDE.md");
@@ -73396,13 +73406,31 @@ defaults:
73396
73406
  PreToolUse:
73397
73407
  - command: "/opt/switchroom-audit.sh"
73398
73408
  timeout: 5
73399
- # Bundled skills that ship with switchroom. \`humanizer\` removes AI-writing
73400
- # patterns from agent replies before they reach Telegram (29 patterns from
73401
- # Wikipedia's "Signs of AI writing" guide). \`humanizer-calibrate\` is its
73402
- # companion that builds a personal voice template from your message history.
73403
- # Remove from this list to disable. Per-agent \`skills:\` is unioned with
73404
- # this default \u2014 don't repeat shared skills in each agent.
73405
- skills: [humanizer, humanizer-calibrate]
73409
+ # Bundled skills that ship with switchroom \u2014 general-purpose capabilities
73410
+ # every agent gets by default. Per-agent \`skills:\` is unioned with this
73411
+ # list; don't repeat shared skills in each agent. Remove an entry here to
73412
+ # disable it fleet-wide.
73413
+ #
73414
+ # docx / pdf / pptx / xlsx \u2014 Office + PDF document handling (read,
73415
+ # extract, generate). Pairs with the
73416
+ # pandoc/imagemagick bins in the base image.
73417
+ # webapp-testing \u2014 Playwright-driven UI checks (browsers are
73418
+ # baked into the agent image at
73419
+ # /opt/playwright/browsers).
73420
+ # mcp-builder \u2014 scaffold + iterate on MCP servers.
73421
+ # skill-creator \u2014 author new Claude Code skills.
73422
+ # file-bug \u2014 structured issue filing into the operator's
73423
+ # tracker.
73424
+ # humanizer \u2014 strips AI-writing patterns from replies
73425
+ # before they reach Telegram (29 patterns
73426
+ # from Wikipedia's "Signs of AI writing").
73427
+ # humanizer-calibrate \u2014 builds a personal voice template from your
73428
+ # message history; feeds humanizer.
73429
+ #
73430
+ # Job-specific / dev-only skills (buildkite-*, telegram-test-harness,
73431
+ # token-helpers, switchroom-*) are intentionally NOT in this default
73432
+ # list \u2014 opt in per-agent where they apply.
73433
+ skills: [docx, pdf, pptx, xlsx, webapp-testing, mcp-builder, skill-creator, file-bug, humanizer, humanizer-calibrate]
73406
73434
  # Optional: point the humanizer at a voice template generated by
73407
73435
  # \`/humanizer-calibrate\`. Without this, falls back to generic rules.
73408
73436
  # humanizer_voice_file: ~/.switchroom/voice.md
@@ -75454,7 +75482,7 @@ function reconcileAgentCronOnly(agent) {
75454
75482
  return { ok: false, error: `agent "${agent}" not in switchroom.yaml` };
75455
75483
  }
75456
75484
  const agentsDir = resolveAgentsDir(config);
75457
- const result = reconcileAgent(agent, agentConfig, agentsDir, config.telegram, config, undefined, {});
75485
+ const result = reconcileAgent(agent, agentConfig, agentsDir, config.telegram, config, undefined, { skipProfileTemplates: true });
75458
75486
  const changes = [...result.changes];
75459
75487
  const nonCron = changes.filter((p) => classifyChangeKind(p) !== "cron");
75460
75488
  if (nonCron.length > 0) {
@@ -9666,6 +9666,8 @@ var init_secretlint_source = __esm(() => {
9666
9666
 
9667
9667
  // src/host-control/main.ts
9668
9668
  import { homedir as homedir3 } from "node:os";
9669
+ import { existsSync as existsSync7 } from "node:fs";
9670
+ import { join as join4, resolve as resolve6 } from "node:path";
9669
9671
 
9670
9672
  // src/config/loader.ts
9671
9673
  import { readFileSync as readFileSync2, existsSync as existsSync3 } from "node:fs";
@@ -14702,9 +14704,11 @@ import {
14702
14704
  readFileSync as readFileSync4,
14703
14705
  writeFileSync as writeFileSync2,
14704
14706
  renameSync,
14705
- mkdirSync
14707
+ mkdirSync,
14708
+ unlinkSync
14706
14709
  } from "node:fs";
14707
14710
  import { join as join3, dirname as dirname2, resolve as resolve5 } from "node:path";
14711
+ import { randomUUID, randomBytes } from "node:crypto";
14708
14712
 
14709
14713
  // src/host-control/protocol.ts
14710
14714
  var MAX_FRAME_BYTES = 64 * 1024;
@@ -15634,7 +15638,7 @@ function validateConfigEdit(opts) {
15634
15638
  const leakErr = secretLeakGuard(beforeData, parsedAfter.data);
15635
15639
  if (leakErr)
15636
15640
  return leakErr;
15637
- return { ok: true };
15641
+ return { ok: true, postApplyContent: applied.after };
15638
15642
  }
15639
15643
 
15640
15644
  // src/host-control/server.ts
@@ -15688,6 +15692,15 @@ function readCachedInstallType(bindRoot) {
15688
15692
  } catch {}
15689
15693
  return payload;
15690
15694
  }
15695
+ var CONFIG_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
15696
+ function defaultApprovalId() {
15697
+ return randomBytes(4).toString("hex");
15698
+ }
15699
+ function unlinkSyncBestEffort(path2) {
15700
+ try {
15701
+ unlinkSync(path2);
15702
+ } catch {}
15703
+ }
15691
15704
  var STATUS_RETENTION_MS = 10 * 60 * 1000;
15692
15705
  var STATUS_MAX_ENTRIES = 256;
15693
15706
  var TAIL_BYTES = 4096;
@@ -15916,7 +15929,7 @@ class HostdServer {
15916
15929
  resp = await this.handleAgentSmoke(req, started);
15917
15930
  break;
15918
15931
  case "config_propose_edit":
15919
- resp = this.handleConfigProposeEdit(req, started);
15932
+ resp = await this.handleConfigProposeEdit(req, caller, started);
15920
15933
  break;
15921
15934
  }
15922
15935
  } catch (err) {
@@ -16213,7 +16226,7 @@ class HostdServer {
16213
16226
  stderr_tail: tail(res.stderr)
16214
16227
  };
16215
16228
  }
16216
- handleConfigProposeEdit(req, started) {
16229
+ async handleConfigProposeEdit(req, caller, started) {
16217
16230
  const enabled = this.opts.config.hostd?.config_edit_enabled === true;
16218
16231
  if (!enabled) {
16219
16232
  return errorResponse(req.request_id, "E_CONFIG_EDIT_DISABLED: config_propose_edit is disabled; " + "operator must set hostd.config_edit_enabled=true in " + "switchroom.yaml to opt in", Date.now() - started);
@@ -16227,7 +16240,96 @@ class HostdServer {
16227
16240
  if (!verdict.ok) {
16228
16241
  return errorResponse(req.request_id, `${verdict.code}: ${verdict.detail}`, Date.now() - started);
16229
16242
  }
16230
- return errorResponse(req.request_id, "E_NOT_IMPLEMENTED_APPLY_PATH: validation passed (apply path " + "not yet implemented — pending PR 1c)", Date.now() - started);
16243
+ if (!this.opts.approvalGateway) {
16244
+ return errorResponse(req.request_id, "E_NO_APPROVAL_GATEWAY: validation passed but hostd was " + "started without an approval-gateway wiring; the operator " + "build is missing the telegram-plugin link", Date.now() - started);
16245
+ }
16246
+ const callerName = caller.kind === "agent" ? caller.name : "operator";
16247
+ const approvalId = (this.opts.generateApprovalId ?? defaultApprovalId)();
16248
+ const approval = await this.opts.approvalGateway.requestApproval({
16249
+ requestId: approvalId,
16250
+ agentName: callerName,
16251
+ reason: req.args.reason,
16252
+ unifiedDiff: req.args.unified_diff,
16253
+ timeoutMs: CONFIG_APPROVAL_TIMEOUT_MS
16254
+ });
16255
+ if (approval.verdict === "deny") {
16256
+ return errorResponse(req.request_id, `E_DENIED: operator denied config_propose_edit (approval_id=${approvalId})`, Date.now() - started);
16257
+ }
16258
+ if (approval.verdict === "timeout") {
16259
+ return errorResponse(req.request_id, `E_APPROVAL_TIMEOUT: operator approval card expired without a tap (approval_id=${approvalId})`, Date.now() - started);
16260
+ }
16261
+ const release = await this.acquireConfigApplyLock();
16262
+ try {
16263
+ let snapshot;
16264
+ try {
16265
+ snapshot = readFileSync4(configPath, "utf-8");
16266
+ } catch (err) {
16267
+ await approval.finalize({
16268
+ outcome: "reconcile_failed_rolled_back",
16269
+ detail: `pre-write snapshot read failed: ${err.message}`
16270
+ });
16271
+ return errorResponse(req.request_id, `E_RECONCILE_FAILED_ROLLED_BACK: snapshot read failed: ${err.message}`, Date.now() - started);
16272
+ }
16273
+ const postApply = verdict.postApplyContent;
16274
+ const tmp = configPath + ".tmp";
16275
+ try {
16276
+ writeFileSync2(tmp, postApply);
16277
+ renameSync(tmp, configPath);
16278
+ } catch (err) {
16279
+ unlinkSyncBestEffort(tmp);
16280
+ await approval.finalize({
16281
+ outcome: "reconcile_failed_rolled_back",
16282
+ detail: `atomic write failed: ${err.message}`
16283
+ });
16284
+ return errorResponse(req.request_id, `E_RECONCILE_FAILED_ROLLED_BACK: write failed: ${err.message}`, Date.now() - started);
16285
+ }
16286
+ const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
16287
+ const recRes = await runner({ requestId: approvalId });
16288
+ if (recRes.exit_code === 0) {
16289
+ await approval.finalize({ outcome: "applied" });
16290
+ return {
16291
+ v: 1,
16292
+ request_id: req.request_id,
16293
+ result: "completed",
16294
+ exit_code: 0,
16295
+ duration_ms: Date.now() - started,
16296
+ stdout_tail: tail(recRes.stdout),
16297
+ stderr_tail: tail(recRes.stderr)
16298
+ };
16299
+ }
16300
+ let rollbackDetail = "";
16301
+ try {
16302
+ writeFileSync2(tmp, snapshot);
16303
+ renameSync(tmp, configPath);
16304
+ } catch (err) {
16305
+ rollbackDetail = `snapshot restore failed: ${err.message}`;
16306
+ await approval.finalize({
16307
+ outcome: "reconcile_failed_rolled_back",
16308
+ detail: rollbackDetail
16309
+ });
16310
+ return errorResponse(req.request_id, `E_RECONCILE_FAILED_ROLLED_BACK: ${rollbackDetail}`, Date.now() - started);
16311
+ }
16312
+ const recRes2 = await runner({ requestId: approvalId });
16313
+ const recoveryNote = recRes2.exit_code === 0 ? "rolled back successfully" : `rolled back but recovery reconcile also failed (exit ${recRes2.exit_code})`;
16314
+ await approval.finalize({
16315
+ outcome: "reconcile_failed_rolled_back",
16316
+ detail: recoveryNote
16317
+ });
16318
+ return errorResponse(req.request_id, `E_RECONCILE_FAILED_ROLLED_BACK: reconcile exit ${recRes.exit_code}; ${recoveryNote}`, Date.now() - started);
16319
+ } finally {
16320
+ release();
16321
+ }
16322
+ }
16323
+ configApplyLock = Promise.resolve();
16324
+ async acquireConfigApplyLock() {
16325
+ let release;
16326
+ const next = new Promise((r) => {
16327
+ release = r;
16328
+ });
16329
+ const prior = this.configApplyLock;
16330
+ this.configApplyLock = prior.then(() => next);
16331
+ await prior;
16332
+ return release;
16231
16333
  }
16232
16334
  async handleAgentSmoke(req, started) {
16233
16335
  const container = `switchroom-${req.args.name}`;
@@ -16529,6 +16631,104 @@ function isSafeExecArgvElement(s) {
16529
16631
  return !/[\u0000-\u001f\u007f]/.test(s);
16530
16632
  }
16531
16633
 
16634
+ // src/host-control/approval-gateway.ts
16635
+ import { connect } from "node:net";
16636
+
16637
+ class SocketApprovalGateway {
16638
+ opts;
16639
+ constructor(opts) {
16640
+ this.opts = opts;
16641
+ }
16642
+ async requestApproval(req) {
16643
+ const sockPath = this.opts.resolveGatewaySocket(req.agentName);
16644
+ if (sockPath === null) {
16645
+ return {
16646
+ verdict: "deny",
16647
+ finalize: async () => {}
16648
+ };
16649
+ }
16650
+ return await new Promise((resolve6) => {
16651
+ const client = connect({ path: sockPath });
16652
+ let buffer = "";
16653
+ let resolved = false;
16654
+ const log = this.opts.log ?? (() => {});
16655
+ const finalize = async (outcome) => {
16656
+ if (client.destroyed)
16657
+ return;
16658
+ try {
16659
+ client.write(JSON.stringify({
16660
+ type: "request_config_finalize",
16661
+ requestId: req.requestId,
16662
+ outcome: outcome.outcome,
16663
+ ...outcome.detail ? { detail: outcome.detail } : {}
16664
+ }) + `
16665
+ `);
16666
+ client.end();
16667
+ } catch (err) {
16668
+ log(`finalize write failed (requestId=${req.requestId}): ${err.message}`);
16669
+ }
16670
+ };
16671
+ client.on("connect", () => {
16672
+ try {
16673
+ client.write(JSON.stringify({
16674
+ type: "request_config_approval",
16675
+ requestId: req.requestId,
16676
+ agentName: req.agentName,
16677
+ reason: req.reason,
16678
+ unifiedDiff: req.unifiedDiff,
16679
+ timeoutMs: req.timeoutMs
16680
+ }) + `
16681
+ `);
16682
+ } catch (err) {
16683
+ if (resolved)
16684
+ return;
16685
+ resolved = true;
16686
+ log(`request_config_approval write failed (requestId=${req.requestId}): ${err.message}`);
16687
+ resolve6({ verdict: "deny", finalize: async () => {} });
16688
+ }
16689
+ });
16690
+ client.on("data", (chunk2) => {
16691
+ buffer += chunk2.toString("utf8");
16692
+ const lines = buffer.split(`
16693
+ `);
16694
+ buffer = lines.pop() ?? "";
16695
+ for (const line of lines) {
16696
+ if (!line.trim())
16697
+ continue;
16698
+ let parsed;
16699
+ try {
16700
+ parsed = JSON.parse(line);
16701
+ } catch {
16702
+ log(`bad JSON from gateway: ${line.slice(0, 200)}`);
16703
+ continue;
16704
+ }
16705
+ const obj = parsed;
16706
+ if (obj.type === "config_approval_resolved" && obj.requestId === req.requestId && (obj.verdict === "approve" || obj.verdict === "deny" || obj.verdict === "timeout") && !resolved) {
16707
+ resolved = true;
16708
+ resolve6({
16709
+ verdict: obj.verdict,
16710
+ finalize
16711
+ });
16712
+ }
16713
+ }
16714
+ });
16715
+ client.on("error", (err) => {
16716
+ if (resolved)
16717
+ return;
16718
+ resolved = true;
16719
+ log(`gateway socket error (requestId=${req.requestId}): ${err.message}`);
16720
+ resolve6({ verdict: "deny", finalize: async () => {} });
16721
+ });
16722
+ client.on("close", () => {
16723
+ if (resolved)
16724
+ return;
16725
+ resolved = true;
16726
+ resolve6({ verdict: "deny", finalize: async () => {} });
16727
+ });
16728
+ });
16729
+ }
16730
+ }
16731
+
16532
16732
  // src/host-control/main.ts
16533
16733
  async function main() {
16534
16734
  const config = loadConfig();
@@ -16547,12 +16747,27 @@ async function main() {
16547
16747
  process.stderr.write("hostd: no admin-flagged agents — nothing to serve. Set `admin: true` on at least one agent.\n");
16548
16748
  process.exit(2);
16549
16749
  }
16750
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join4(homedir3(), ".switchroom", "agents");
16751
+ const approvalGateway = new SocketApprovalGateway({
16752
+ resolveGatewaySocket: (agentName) => {
16753
+ const sock = resolve6(agentsDir, agentName, "telegram", "gateway.sock");
16754
+ return existsSync7(sock) ? sock : null;
16755
+ },
16756
+ log: (m) => process.stderr.write(`hostd: approval-gateway — ${m}
16757
+ `)
16758
+ });
16550
16759
  const server = new HostdServer({
16551
16760
  homeDir: homedir3(),
16552
16761
  agentUids,
16553
16762
  config: {
16554
- agents: Object.fromEntries(Object.entries(config.agents).map(([n, a]) => [n, { admin: a.admin === true }]))
16555
- }
16763
+ agents: Object.fromEntries(Object.entries(config.agents).map(([n, a]) => [n, { admin: a.admin === true }])),
16764
+ ...config.hostd ? {
16765
+ hostd: {
16766
+ ...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {}
16767
+ }
16768
+ } : {}
16769
+ },
16770
+ approvalGateway
16556
16771
  });
16557
16772
  await server.start();
16558
16773
  const paths = server.getBoundPaths();
@@ -71,13 +71,31 @@ defaults:
71
71
  PreToolUse:
72
72
  - command: "/opt/switchroom-audit.sh"
73
73
  timeout: 5
74
- # Bundled skills that ship with switchroom. `humanizer` removes AI-writing
75
- # patterns from agent replies before they reach Telegram (29 patterns from
76
- # Wikipedia's "Signs of AI writing" guide). `humanizer-calibrate` is its
77
- # companion that builds a personal voice template from your message history.
78
- # Remove from this list to disable. Per-agent `skills:` is unioned with
79
- # this defaultdon't repeat shared skills in each agent.
80
- skills: [humanizer, humanizer-calibrate]
74
+ # Bundled skills that ship with switchroom general-purpose capabilities
75
+ # every agent gets by default. Per-agent `skills:` is unioned with this
76
+ # list; don't repeat shared skills in each agent. Remove an entry here to
77
+ # disable it fleet-wide.
78
+ #
79
+ # docx / pdf / pptx / xlsx Office + PDF document handling (read,
80
+ # extract, generate). Pairs with the
81
+ # pandoc/imagemagick bins in the base image.
82
+ # webapp-testing — Playwright-driven UI checks (browsers are
83
+ # baked into the agent image at
84
+ # /opt/playwright/browsers).
85
+ # mcp-builder — scaffold + iterate on MCP servers.
86
+ # skill-creator — author new Claude Code skills.
87
+ # file-bug — structured issue filing into the operator's
88
+ # tracker.
89
+ # humanizer — strips AI-writing patterns from replies
90
+ # before they reach Telegram (29 patterns
91
+ # from Wikipedia's "Signs of AI writing").
92
+ # humanizer-calibrate — builds a personal voice template from your
93
+ # message history; feeds humanizer.
94
+ #
95
+ # Job-specific / dev-only skills (buildkite-*, telegram-test-harness,
96
+ # token-helpers, switchroom-*) are intentionally NOT in this default
97
+ # list — opt in per-agent where they apply.
98
+ skills: [docx, pdf, pptx, xlsx, webapp-testing, mcp-builder, skill-creator, file-bug, humanizer, humanizer-calibrate]
81
99
  # Optional: point the humanizer at a voice template generated by
82
100
  # `/humanizer-calibrate`. Without this, falls back to generic rules.
83
101
  # humanizer_voice_file: ~/.switchroom/voice.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.8",
3
+ "version": "0.13.10",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,10 +5,10 @@ Telegram is a chat — replies should feel like one, not a terminal dump or a tr
5
5
  **Every turn that responds to a user message MUST end with a `reply` (or `stream_reply` with `done=true`).** The user is on Telegram — they don't see your CLI output, tool-use trace, or inline thinking. The ONLY path for words to reach them is an MCP tool call. If you have a final answer, send it via `reply`. The text in your terminal is not the conversation.
6
6
 
7
7
  **Conversational pacing — a human is on the other side.** Match the rhythm of a capable colleague messaging you back. Five beats:
8
- - **1 · Acknowledge first.** Your first action on any turn that needs real work a file read, a search, a command — is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it — checking now"*. Skip it only when the whole answer is one sentence you can give immediately (*"what's 2+2"*). This is a beat, not filler — it's the line between a colleague and a black box.
8
+ - **1 · Acknowledge first.** Unless your whole reply is a single short sentence you can send right now, your first action is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it — checking now"*. This holds whether the work ahead is a tool call or a paragraph of pure reasoning — if the answer will run long, ack *before* you compose it. Skip the ack only for an immediate one-sentence answer (*"what's 2+2"*). This is a beat, not filler — it's the line between a colleague and a black box.
9
9
  - **2 · Then go quiet and work.** Heads-down is right — do **not** narrate every tool call. A typing indicator runs for you automatically; you don't keep it alive.
10
10
  - **3 · Surface meaningful progress** at genuine inflection points — a hard step finished, a blocker, a pivot, dispatching a sub-agent, a notably slow wait, a finding worth knowing now. One short `reply`, `disable_notification: true` (no mid-turn ping).
11
- - **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice — what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply.
11
+ - **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice — what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply. A *background* worker finishes after your turn has ended — its result is delivered to you as a fresh `<channel source="subagent_handback">` turn. That turn IS your cue: synthesise it for the user right then; don't treat it as noise and don't stay silent.
12
12
  - **5 · Deliver the answer** as a fresh `reply` (omit `disable_notification` — pings once).
13
13
 
14
14
  The one thing to avoid is **spam**: a reply on every tool call, on a cadence, or repeating yourself. Responsive and human, never a flood. A `<system-reminder>` containing `[silence-poke]` means you've gone quiet too long — send one short `reply` and carry on; skip it only if you're within ~5s of finishing.