hovclaw 0.1.0 → 0.1.2

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.
package/dist/index.js CHANGED
@@ -11,11 +11,11 @@ import pino from "pino";
11
11
  import { AsyncLocalStorage } from "node:async_hooks";
12
12
  import { parse } from "yaml";
13
13
  import { Client, GatewayIntentBits, Partials } from "discord.js";
14
- import Database from "better-sqlite3";
15
- import crypto, { randomUUID } from "node:crypto";
14
+ import crypto, { randomUUID, timingSafeEqual } from "node:crypto";
15
+ import { spawn, spawnSync } from "node:child_process";
16
16
  import { fileURLToPath } from "node:url";
17
+ import Database from "better-sqlite3";
17
18
  import WebSocket, { WebSocketServer } from "ws";
18
- import { spawn, spawnSync } from "node:child_process";
19
19
  import { JSDOM } from "jsdom";
20
20
  import { Readability } from "@mozilla/readability";
21
21
  import { Cron } from "croner";
@@ -25,6 +25,12 @@ import Parser from "rss-parser";
25
25
  dotenv.config();
26
26
  const PROJECT_ROOT = process.cwd();
27
27
  const DEFAULT_WORKSPACE_PATH = "~/.hovclaw/workspace";
28
+ const DEFAULT_SHARED_SKILLS_PATH = "~/.agents/skills";
29
+ const USER_CONTENT_DIRS = ["agents"];
30
+ const USER_STATE_DIRS = ["store", "data"];
31
+ const ensuredHomeContentDirs = /* @__PURE__ */ new Set();
32
+ const ensuredHomeStateDirs = /* @__PURE__ */ new Set();
33
+ const ensuredSharedSkillsDirs = /* @__PURE__ */ new Set();
28
34
  const DEFAULT_FILE_CONFIG = {
29
35
  assistant: { name: "Andy" },
30
36
  agents: {
@@ -55,6 +61,18 @@ const DEFAULT_FILE_CONFIG = {
55
61
  aliases: {},
56
62
  allowlist: []
57
63
  },
64
+ commands: {
65
+ native: "auto",
66
+ nativeSkills: "auto",
67
+ defaultThinkingLevel: "medium",
68
+ text: true,
69
+ config: false,
70
+ debug: false,
71
+ bash: false,
72
+ restart: false,
73
+ useAccessGroups: true,
74
+ allowFrom: {}
75
+ },
58
76
  runtime: {
59
77
  mode: "local",
60
78
  containerImage: "ghcr.io/mariozechner/pi-agent:latest",
@@ -78,7 +96,8 @@ const DEFAULT_FILE_CONFIG = {
78
96
  "head",
79
97
  "tail",
80
98
  "wc"
81
- ]
99
+ ],
100
+ tools: { bashEnabled: false }
82
101
  },
83
102
  channels: {
84
103
  discord: {
@@ -132,7 +151,9 @@ const DEFAULT_FILE_CONFIG = {
132
151
  },
133
152
  auth: {
134
153
  token: "",
135
- password: ""
154
+ password: "",
155
+ allowUnauthenticated: false,
156
+ allowedOrigins: []
136
157
  },
137
158
  remote: {
138
159
  url: "",
@@ -151,11 +172,36 @@ const telegramWebhookSchema = z.object({
151
172
  path: z.string().min(1),
152
173
  port: z.number().int().positive(),
153
174
  secret: z.string()
175
+ }).superRefine((value, ctx) => {
176
+ if (!value.enabled) return;
177
+ if (value.secret.trim().length > 0) return;
178
+ ctx.addIssue({
179
+ code: z.ZodIssueCode.custom,
180
+ message: "Webhook secret is required when webhook mode is enabled.",
181
+ path: ["secret"]
182
+ });
154
183
  });
155
184
  const telegramCustomCommandSchema = z.object({
156
185
  command: z.string().min(1),
157
186
  description: z.string().min(1)
158
187
  });
188
+ const commandAllowFromSchema = z.record(z.string(), z.array(z.union([z.string(), z.number()])));
189
+ const commandsConfigSchema = z.object({
190
+ native: z.union([z.boolean(), z.literal("auto")]),
191
+ nativeSkills: z.union([z.boolean(), z.literal("auto")]),
192
+ defaultThinkingLevel: z.enum([
193
+ "low",
194
+ "medium",
195
+ "high"
196
+ ]),
197
+ text: z.boolean(),
198
+ config: z.boolean(),
199
+ debug: z.boolean(),
200
+ bash: z.boolean(),
201
+ restart: z.boolean(),
202
+ useAccessGroups: z.boolean(),
203
+ allowFrom: commandAllowFromSchema
204
+ });
159
205
  const telegramTopicConfigSchema = z.object({
160
206
  enabled: z.boolean().optional(),
161
207
  requireMention: z.boolean().optional(),
@@ -215,6 +261,46 @@ const telegramAccountConfigSchema = z.object({
215
261
  ]).optional(),
216
262
  textMode: z.enum(["plain", "markdown"]).optional()
217
263
  });
264
+ const partialTelegramWebhookSchema = z.object({
265
+ enabled: z.boolean().optional(),
266
+ path: z.string().min(1).optional(),
267
+ port: z.number().int().positive().optional(),
268
+ secret: z.string().optional()
269
+ });
270
+ const partialTelegramAccountConfigSchema = z.object({
271
+ enabled: z.boolean().optional(),
272
+ name: z.string().optional(),
273
+ botToken: z.string().optional(),
274
+ webhook: partialTelegramWebhookSchema.optional(),
275
+ dmPolicy: z.enum([
276
+ "pairing",
277
+ "allowlist",
278
+ "open",
279
+ "disabled"
280
+ ]).optional(),
281
+ groupPolicy: z.enum([
282
+ "open",
283
+ "allowlist",
284
+ "disabled"
285
+ ]).optional(),
286
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
287
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
288
+ groups: z.record(z.string(), telegramGroupConfigSchema).optional(),
289
+ commands: z.union([z.boolean(), z.literal("auto")]).optional(),
290
+ customCommands: z.array(telegramCustomCommandSchema).optional(),
291
+ reactionNotifications: z.enum([
292
+ "off",
293
+ "own",
294
+ "all"
295
+ ]).optional(),
296
+ reactionLevel: z.enum([
297
+ "off",
298
+ "ack",
299
+ "minimal",
300
+ "extensive"
301
+ ]).optional(),
302
+ textMode: z.enum(["plain", "markdown"]).optional()
303
+ });
218
304
  const fileConfigSchema = z.object({
219
305
  assistant: z.object({ name: z.string().min(1) }),
220
306
  agents: z.object({
@@ -267,6 +353,7 @@ const fileConfigSchema = z.object({
267
353
  aliases: z.record(z.string(), z.string()),
268
354
  allowlist: z.array(z.string())
269
355
  }),
356
+ commands: commandsConfigSchema,
270
357
  runtime: z.object({
271
358
  mode: z.enum(["local", "container"]),
272
359
  containerImage: z.string().min(1),
@@ -276,7 +363,8 @@ const fileConfigSchema = z.object({
276
363
  maxOutputBytes: z.number().int().positive(),
277
364
  allowedReadRoots: z.array(z.string().min(1)),
278
365
  allowedWriteRoots: z.array(z.string().min(1)),
279
- allowedCommandPrefixes: z.array(z.string().min(1)).min(1)
366
+ allowedCommandPrefixes: z.array(z.string().min(1)).min(1),
367
+ tools: z.object({ bashEnabled: z.boolean() })
280
368
  }),
281
369
  channels: z.object({
282
370
  discord: z.object({
@@ -305,7 +393,9 @@ const fileConfigSchema = z.object({
305
393
  }),
306
394
  auth: z.object({
307
395
  token: z.string(),
308
- password: z.string()
396
+ password: z.string(),
397
+ allowUnauthenticated: z.boolean(),
398
+ allowedOrigins: z.array(z.string().min(1))
309
399
  }),
310
400
  remote: z.object({
311
401
  url: z.string(),
@@ -323,21 +413,37 @@ const partialTelegramConfigSchema = z.object({
323
413
  enabled: z.boolean().optional(),
324
414
  botToken: z.string().optional(),
325
415
  defaultAccountId: z.string().optional(),
326
- webhook: telegramWebhookSchema.partial().optional(),
327
- accounts: z.record(z.string(), telegramAccountConfigSchema.partial()).optional()
416
+ webhook: partialTelegramWebhookSchema.optional(),
417
+ accounts: z.record(z.string(), partialTelegramAccountConfigSchema).optional()
328
418
  }).optional();
329
419
  const partialChannelsSchema = z.object({
330
420
  discord: fileConfigSchema.shape.channels.shape.discord.partial().optional(),
331
421
  telegram: partialTelegramConfigSchema
332
422
  }).optional();
423
+ const partialGatewayConfigSchema = z.object({
424
+ enabled: z.boolean().optional(),
425
+ host: z.string().min(1).optional(),
426
+ port: z.number().int().positive().optional(),
427
+ mode: z.enum(["local", "remote"]).optional(),
428
+ tickIntervalMs: z.number().int().positive().optional(),
429
+ webUi: fileConfigSchema.shape.gateway.shape.webUi.partial().optional(),
430
+ auth: z.object({
431
+ token: z.string().optional(),
432
+ password: z.string().optional(),
433
+ allowUnauthenticated: z.boolean().optional(),
434
+ allowedOrigins: z.array(z.string().min(1)).optional()
435
+ }).optional(),
436
+ remote: fileConfigSchema.shape.gateway.shape.remote.partial().optional()
437
+ }).optional();
333
438
  const partialFileConfigSchema = z.object({
334
439
  assistant: fileConfigSchema.shape.assistant.partial().optional(),
335
440
  agents: fileConfigSchema.shape.agents.partial().optional(),
336
441
  bindings: fileConfigSchema.shape.bindings.optional(),
337
442
  models: fileConfigSchema.shape.models.partial().optional(),
443
+ commands: commandsConfigSchema.partial().optional(),
338
444
  runtime: fileConfigSchema.shape.runtime.partial().optional(),
339
445
  channels: partialChannelsSchema,
340
- gateway: fileConfigSchema.shape.gateway.partial().optional(),
446
+ gateway: partialGatewayConfigSchema,
341
447
  scheduler: fileConfigSchema.shape.scheduler.partial().optional()
342
448
  });
343
449
  const apiKeyCredentialSchema = z.object({
@@ -398,12 +504,97 @@ function defaultHovclawHome() {
398
504
  function getHovclawHome(env = process.env) {
399
505
  return path.resolve(expandPath(env.HOVCLAW_HOME || defaultHovclawHome()));
400
506
  }
507
+ function defaultSharedSkillsDir(env = process.env) {
508
+ if ((env.NODE_ENV || "production") === "test") return path.join(getHovclawHome(env), ".agents", "skills");
509
+ return DEFAULT_SHARED_SKILLS_PATH;
510
+ }
511
+ function getSharedSkillsDir(env = process.env) {
512
+ const configured = env.HOVCLAW_SKILLS_DIR?.trim();
513
+ return path.resolve(expandPath(configured || defaultSharedSkillsDir(env)));
514
+ }
401
515
  function getConfigPath(env = process.env) {
402
516
  return path.join(getHovclawHome(env), "config.json");
403
517
  }
404
518
  function getCredentialsPath(env = process.env) {
405
519
  return path.join(getHovclawHome(env), "credentials.json");
406
520
  }
521
+ function ensureHomeContentDirs(projectRoot, hovclawHome) {
522
+ for (const dirName of USER_CONTENT_DIRS) {
523
+ const destinationDir = path.join(hovclawHome, dirName);
524
+ if (fs.existsSync(destinationDir)) continue;
525
+ fs.mkdirSync(destinationDir, {
526
+ recursive: true,
527
+ mode: 448
528
+ });
529
+ }
530
+ }
531
+ function ensureMainAgentConfig(agentsDir) {
532
+ const mainAgentPath = path.join(agentsDir, "main", "agent.json");
533
+ if (fs.existsSync(mainAgentPath)) return;
534
+ writeJsonFile(mainAgentPath, {
535
+ name: "Main",
536
+ skills: []
537
+ });
538
+ }
539
+ function ensureHomeStateDirs(projectRoot, hovclawHome) {
540
+ for (const dirName of USER_STATE_DIRS) {
541
+ const destinationDir = path.join(hovclawHome, dirName);
542
+ if (fs.existsSync(destinationDir)) continue;
543
+ const sourceDir = path.join(projectRoot, dirName);
544
+ if (fs.existsSync(sourceDir)) {
545
+ fs.cpSync(sourceDir, destinationDir, { recursive: true });
546
+ continue;
547
+ }
548
+ fs.mkdirSync(destinationDir, {
549
+ recursive: true,
550
+ mode: 448
551
+ });
552
+ }
553
+ }
554
+ function ensureHomeContentDirsOnce(projectRoot, hovclawHome) {
555
+ if (ensuredHomeContentDirs.has(hovclawHome)) return;
556
+ ensureHomeContentDirs(projectRoot, hovclawHome);
557
+ ensuredHomeContentDirs.add(hovclawHome);
558
+ }
559
+ function ensureHomeStateDirsOnce(projectRoot, hovclawHome) {
560
+ if (ensuredHomeStateDirs.has(hovclawHome)) return;
561
+ ensureHomeStateDirs(projectRoot, hovclawHome);
562
+ ensuredHomeStateDirs.add(hovclawHome);
563
+ }
564
+ function directoryHasEntries(dirPath) {
565
+ if (!fs.existsSync(dirPath)) return false;
566
+ try {
567
+ return fs.readdirSync(dirPath).length > 0;
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+ function copyDirectoryEntries(sourceDir, destinationDir) {
573
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
574
+ const sourcePath = path.join(sourceDir, entry.name);
575
+ const destinationPath = path.join(destinationDir, entry.name);
576
+ fs.cpSync(sourcePath, destinationPath, {
577
+ recursive: true,
578
+ force: true
579
+ });
580
+ }
581
+ }
582
+ function ensureSharedSkillsDir(hovclawHome, env = process.env) {
583
+ const sharedSkillsDir = getSharedSkillsDir(env);
584
+ ensureSecureDir(sharedSkillsDir);
585
+ if (directoryHasEntries(sharedSkillsDir)) return sharedSkillsDir;
586
+ const legacySkillsDir = path.join(hovclawHome, "skills");
587
+ if (!directoryHasEntries(legacySkillsDir)) return sharedSkillsDir;
588
+ copyDirectoryEntries(legacySkillsDir, sharedSkillsDir);
589
+ return sharedSkillsDir;
590
+ }
591
+ function ensureSharedSkillsDirOnce(hovclawHome, env = process.env) {
592
+ const sharedSkillsDir = getSharedSkillsDir(env);
593
+ if (ensuredSharedSkillsDirs.has(sharedSkillsDir)) return sharedSkillsDir;
594
+ ensureSharedSkillsDir(hovclawHome, env);
595
+ ensuredSharedSkillsDirs.add(sharedSkillsDir);
596
+ return sharedSkillsDir;
597
+ }
407
598
  function hasConfigFile(env = process.env) {
408
599
  return fs.existsSync(getConfigPath(env));
409
600
  }
@@ -499,6 +690,18 @@ function mergeWithDefaults(partial) {
499
690
  aliases: partial.models?.aliases ?? DEFAULT_FILE_CONFIG.models.aliases,
500
691
  allowlist: partial.models?.allowlist ?? DEFAULT_FILE_CONFIG.models.allowlist
501
692
  },
693
+ commands: {
694
+ native: partial.commands?.native ?? DEFAULT_FILE_CONFIG.commands.native,
695
+ nativeSkills: partial.commands?.nativeSkills ?? DEFAULT_FILE_CONFIG.commands.nativeSkills,
696
+ defaultThinkingLevel: partial.commands?.defaultThinkingLevel ?? DEFAULT_FILE_CONFIG.commands.defaultThinkingLevel,
697
+ text: partial.commands?.text ?? DEFAULT_FILE_CONFIG.commands.text,
698
+ config: partial.commands?.config ?? DEFAULT_FILE_CONFIG.commands.config,
699
+ debug: partial.commands?.debug ?? DEFAULT_FILE_CONFIG.commands.debug,
700
+ bash: partial.commands?.bash ?? DEFAULT_FILE_CONFIG.commands.bash,
701
+ restart: partial.commands?.restart ?? DEFAULT_FILE_CONFIG.commands.restart,
702
+ useAccessGroups: partial.commands?.useAccessGroups ?? DEFAULT_FILE_CONFIG.commands.useAccessGroups,
703
+ allowFrom: partial.commands?.allowFrom ?? DEFAULT_FILE_CONFIG.commands.allowFrom
704
+ },
502
705
  runtime: {
503
706
  mode: partial.runtime?.mode ?? DEFAULT_FILE_CONFIG.runtime.mode,
504
707
  containerImage: partial.runtime?.containerImage ?? DEFAULT_FILE_CONFIG.runtime.containerImage,
@@ -508,7 +711,8 @@ function mergeWithDefaults(partial) {
508
711
  maxOutputBytes: partial.runtime?.maxOutputBytes ?? DEFAULT_FILE_CONFIG.runtime.maxOutputBytes,
509
712
  allowedReadRoots: partial.runtime?.allowedReadRoots ?? DEFAULT_FILE_CONFIG.runtime.allowedReadRoots,
510
713
  allowedWriteRoots: partial.runtime?.allowedWriteRoots ?? DEFAULT_FILE_CONFIG.runtime.allowedWriteRoots,
511
- allowedCommandPrefixes: partial.runtime?.allowedCommandPrefixes ?? DEFAULT_FILE_CONFIG.runtime.allowedCommandPrefixes
714
+ allowedCommandPrefixes: partial.runtime?.allowedCommandPrefixes ?? DEFAULT_FILE_CONFIG.runtime.allowedCommandPrefixes,
715
+ tools: { bashEnabled: partial.runtime?.tools?.bashEnabled ?? DEFAULT_FILE_CONFIG.runtime.tools.bashEnabled }
512
716
  },
513
717
  channels: {
514
718
  discord: {
@@ -531,7 +735,9 @@ function mergeWithDefaults(partial) {
531
735
  },
532
736
  auth: {
533
737
  token: partial.gateway?.auth?.token ?? DEFAULT_FILE_CONFIG.gateway.auth.token,
534
- password: partial.gateway?.auth?.password ?? DEFAULT_FILE_CONFIG.gateway.auth.password
738
+ password: partial.gateway?.auth?.password ?? DEFAULT_FILE_CONFIG.gateway.auth.password,
739
+ allowUnauthenticated: partial.gateway?.auth?.allowUnauthenticated ?? DEFAULT_FILE_CONFIG.gateway.auth.allowUnauthenticated,
740
+ allowedOrigins: partial.gateway?.auth?.allowedOrigins ?? DEFAULT_FILE_CONFIG.gateway.auth.allowedOrigins
535
741
  },
536
742
  remote: {
537
743
  url: partial.gateway?.remote?.url ?? DEFAULT_FILE_CONFIG.gateway.remote.url,
@@ -596,6 +802,18 @@ function applyEnvOverrides(base, env) {
596
802
  aliases: base.models.aliases,
597
803
  allowlist: base.models.allowlist
598
804
  },
805
+ commands: {
806
+ native: base.commands.native,
807
+ nativeSkills: base.commands.nativeSkills,
808
+ defaultThinkingLevel: env.COMMANDS_DEFAULT_THINKING_LEVEL === "low" || env.COMMANDS_DEFAULT_THINKING_LEVEL === "medium" || env.COMMANDS_DEFAULT_THINKING_LEVEL === "high" ? env.COMMANDS_DEFAULT_THINKING_LEVEL : base.commands.defaultThinkingLevel,
809
+ text: toBool(env.COMMANDS_TEXT, base.commands.text),
810
+ config: toBool(env.COMMANDS_CONFIG, base.commands.config),
811
+ debug: toBool(env.COMMANDS_DEBUG, base.commands.debug),
812
+ bash: toBool(env.COMMANDS_BASH, base.commands.bash),
813
+ restart: toBool(env.COMMANDS_RESTART, base.commands.restart),
814
+ useAccessGroups: toBool(env.COMMANDS_USE_ACCESS_GROUPS, base.commands.useAccessGroups),
815
+ allowFrom: base.commands.allowFrom
816
+ },
599
817
  runtime: {
600
818
  mode: env.RUNTIME_MODE === "container" || env.RUNTIME_MODE === "local" ? env.RUNTIME_MODE : base.runtime.mode,
601
819
  containerImage: env.RUNTIME_CONTAINER_IMAGE || base.runtime.containerImage,
@@ -605,7 +823,8 @@ function applyEnvOverrides(base, env) {
605
823
  maxOutputBytes: toPositiveInt(env.TOOL_MAX_OUTPUT_BYTES, base.runtime.maxOutputBytes),
606
824
  allowedReadRoots: splitCsv(env.ALLOWED_READ_ROOTS, base.runtime.allowedReadRoots),
607
825
  allowedWriteRoots: splitCsv(env.ALLOWED_WRITE_ROOTS, base.runtime.allowedWriteRoots),
608
- allowedCommandPrefixes: splitCsv(env.ALLOWED_COMMAND_PREFIXES, base.runtime.allowedCommandPrefixes)
826
+ allowedCommandPrefixes: splitCsv(env.ALLOWED_COMMAND_PREFIXES, base.runtime.allowedCommandPrefixes),
827
+ tools: { bashEnabled: toBool(env.RUNTIME_BASH_ENABLED, base.runtime.tools.bashEnabled) }
609
828
  },
610
829
  channels: {
611
830
  discord: {
@@ -634,7 +853,9 @@ function applyEnvOverrides(base, env) {
634
853
  },
635
854
  auth: {
636
855
  token: env.GATEWAY_AUTH_TOKEN || base.gateway.auth.token,
637
- password: env.GATEWAY_AUTH_PASSWORD || base.gateway.auth.password
856
+ password: env.GATEWAY_AUTH_PASSWORD || base.gateway.auth.password,
857
+ allowUnauthenticated: toBool(env.GATEWAY_ALLOW_UNAUTHENTICATED, base.gateway.auth.allowUnauthenticated),
858
+ allowedOrigins: splitCsv(env.GATEWAY_ALLOWED_ORIGINS, base.gateway.auth.allowedOrigins)
638
859
  },
639
860
  remote: {
640
861
  url: env.GATEWAY_REMOTE_URL || base.gateway.remote.url,
@@ -655,21 +876,33 @@ function escapeRegex(value) {
655
876
  function normalizeRoots(paths) {
656
877
  return paths.map((entry) => path.resolve(expandPath(entry)));
657
878
  }
879
+ function isLegacyProjectRootWorkspace(workspacePath, projectRoot) {
880
+ if (!workspacePath) return false;
881
+ const trimmed = workspacePath.trim();
882
+ if (!trimmed) return false;
883
+ return path.resolve(expandPath(trimmed)) === path.resolve(projectRoot);
884
+ }
658
885
  function loadConfig(env = process.env) {
659
886
  const merged = applyEnvOverrides(loadConfigFile(env), env);
660
887
  const hovclawHome = getHovclawHome(env);
888
+ const agentsDir = path.join(hovclawHome, "agents");
889
+ ensureHomeContentDirsOnce(PROJECT_ROOT, hovclawHome);
890
+ ensureHomeStateDirsOnce(PROJECT_ROOT, hovclawHome);
891
+ ensureMainAgentConfig(agentsDir);
892
+ const sharedSkillsDir = ensureSharedSkillsDirOnce(hovclawHome, env);
661
893
  const defaultWorkspace = path.join(hovclawHome, "workspace");
662
- const effectiveWorkspace = merged.agents.defaults.workspace.trim() || defaultWorkspace;
894
+ const configuredWorkspace = merged.agents.defaults.workspace.trim();
895
+ const effectiveWorkspace = !configuredWorkspace || isLegacyProjectRootWorkspace(configuredWorkspace, PROJECT_ROOT) ? defaultWorkspace : configuredWorkspace;
663
896
  const assistantName = merged.assistant.name;
664
897
  return {
665
898
  projectRoot: PROJECT_ROOT,
666
899
  hovclawHome,
667
900
  configPath: getConfigPath(env),
668
901
  credentialsPath: getCredentialsPath(env),
669
- agentsDir: path.join(PROJECT_ROOT, "agents"),
670
- skillsDir: path.join(PROJECT_ROOT, "skills"),
671
- storeDir: path.join(PROJECT_ROOT, "store"),
672
- dataDir: path.join(PROJECT_ROOT, "data"),
902
+ agentsDir,
903
+ skillsDir: sharedSkillsDir,
904
+ storeDir: path.join(hovclawHome, "store"),
905
+ dataDir: path.join(hovclawHome, "data"),
673
906
  environment: env.NODE_ENV || "development",
674
907
  logLevel: env.LOG_LEVEL || "info",
675
908
  assistantName,
@@ -679,12 +912,13 @@ function loadConfig(env = process.env) {
679
912
  list: merged.agents.list.map((entry) => ({
680
913
  id: entry.id,
681
914
  name: entry.name,
682
- workspace: entry.workspace ? path.resolve(expandPath(entry.workspace)) : void 0,
915
+ workspace: entry.workspace ? isLegacyProjectRootWorkspace(entry.workspace, PROJECT_ROOT) ? path.resolve(expandPath(defaultWorkspace)) : path.resolve(expandPath(entry.workspace)) : void 0,
683
916
  default: entry.default
684
917
  }))
685
918
  },
686
919
  bindings: merged.bindings,
687
920
  models: { ...merged.models },
921
+ commands: { ...merged.commands },
688
922
  runtime: {
689
923
  mode: merged.runtime.mode,
690
924
  containerImage: merged.runtime.containerImage,
@@ -694,7 +928,8 @@ function loadConfig(env = process.env) {
694
928
  maxOutputBytes: merged.runtime.maxOutputBytes,
695
929
  allowedReadRoots: normalizeRoots(merged.runtime.allowedReadRoots),
696
930
  allowedWriteRoots: normalizeRoots(merged.runtime.allowedWriteRoots),
697
- allowedCommandPrefixes: merged.runtime.allowedCommandPrefixes
931
+ allowedCommandPrefixes: merged.runtime.allowedCommandPrefixes,
932
+ tools: { bashEnabled: merged.runtime.tools.bashEnabled }
698
933
  },
699
934
  channels: {
700
935
  discord: {
@@ -731,7 +966,9 @@ function loadConfig(env = process.env) {
731
966
  },
732
967
  auth: {
733
968
  token: merged.gateway.auth.token,
734
- password: merged.gateway.auth.password
969
+ password: merged.gateway.auth.password,
970
+ allowUnauthenticated: merged.gateway.auth.allowUnauthenticated,
971
+ allowedOrigins: merged.gateway.auth.allowedOrigins
735
972
  },
736
973
  remote: {
737
974
  url: merged.gateway.remote.url,
@@ -789,6 +1026,7 @@ function legacyEnvKeys() {
789
1026
  "ALLOWED_READ_ROOTS",
790
1027
  "ALLOWED_WRITE_ROOTS",
791
1028
  "ALLOWED_COMMAND_PREFIXES",
1029
+ "RUNTIME_BASH_ENABLED",
792
1030
  "TOOL_TIMEOUT_MS",
793
1031
  "TOOL_MAX_OUTPUT_BYTES",
794
1032
  "ENABLE_DISCORD",
@@ -808,6 +1046,8 @@ function legacyEnvKeys() {
808
1046
  "GATEWAY_TICK_INTERVAL_MS",
809
1047
  "GATEWAY_AUTH_TOKEN",
810
1048
  "GATEWAY_AUTH_PASSWORD",
1049
+ "GATEWAY_ALLOW_UNAUTHENTICATED",
1050
+ "GATEWAY_ALLOWED_ORIGINS",
811
1051
  "GATEWAY_REMOTE_URL",
812
1052
  "GATEWAY_REMOTE_TOKEN",
813
1053
  "GATEWAY_REMOTE_PASSWORD",
@@ -1115,12 +1355,14 @@ function chunkText(text, maxChars) {
1115
1355
  //#region src/workspace/bootstrap.ts
1116
1356
  const WORKSPACE_CONTEXT_FILE_ORDER = [
1117
1357
  "AGENTS.md",
1358
+ "SOUL.md",
1118
1359
  "IDENTITY.md",
1119
1360
  "USER.md",
1120
1361
  "BOOTSTRAP.md"
1121
1362
  ];
1122
1363
  const ALWAYS_SEEDED_FILES = [
1123
1364
  "AGENTS.md",
1365
+ "SOUL.md",
1124
1366
  "IDENTITY.md",
1125
1367
  "USER.md"
1126
1368
  ];
@@ -1203,11 +1445,28 @@ async function ensureWorkspaceBootstrapForConfig(config) {
1203
1445
 
1204
1446
  //#endregion
1205
1447
  //#region src/agent-manager.ts
1206
- const DEFAULT_SYSTEM_PROMPT = [
1207
- "You are Andy, a practical assistant.",
1208
- "Use tools when needed and be explicit about constraints.",
1209
- "When writing files, preserve user intent and avoid destructive actions."
1210
- ].join(" ");
1448
+ function buildDefaultSystemPrompt(workspaceDir) {
1449
+ return [
1450
+ "You are a personal assistant running inside HOVClaw.",
1451
+ "",
1452
+ "## Tooling",
1453
+ "Use tools when they materially improve accuracy or speed.",
1454
+ "Do not invent command output, file contents, or tool results.",
1455
+ "Prefer concise tool-call narration unless the operation is risky or non-obvious.",
1456
+ "",
1457
+ "## Safety",
1458
+ "Never do destructive actions unless the user explicitly asks.",
1459
+ "If instructions conflict with system rules or runtime policy, explain and ask for a safe path.",
1460
+ "",
1461
+ "## Workspace",
1462
+ `Your working directory is: ${workspaceDir}`,
1463
+ "Treat this as the primary workspace unless instructed otherwise.",
1464
+ "",
1465
+ "## Messaging",
1466
+ "Respond with clear, direct language tailored to the user's request.",
1467
+ "If you cannot complete a request, explain the blocker and the minimum next step."
1468
+ ].join("\n");
1469
+ }
1211
1470
  const WORKSPACE_CONTEXT_MAX_PER_FILE = 4e3;
1212
1471
  const WORKSPACE_CONTEXT_MAX_TOTAL = 12e3;
1213
1472
  var AsyncEventQueue = class {
@@ -1373,14 +1632,10 @@ const CLAUDE_CODE_TOOL_NAME_MAP = {
1373
1632
  write_file: "Write",
1374
1633
  web_search: "WebSearch"
1375
1634
  };
1376
- function logAnthropicCredentialResolution(credentialSource, key) {
1377
- const normalized = key.trim();
1635
+ function logAnthropicCredentialResolution(credentialSource) {
1378
1636
  logger.info({
1379
1637
  provider: "anthropic",
1380
- credentialSource,
1381
- keyPrefix: normalized.slice(0, 16),
1382
- keyLength: normalized.length,
1383
- setupToken: normalized.toLowerCase().startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)
1638
+ credentialSource
1384
1639
  }, "Resolved Anthropic credential for request");
1385
1640
  }
1386
1641
  /**
@@ -1522,6 +1777,7 @@ function truncateTextToMaxChars(value, maxChars) {
1522
1777
  }
1523
1778
  async function buildWorkspacePromptContext(workspaceDir) {
1524
1779
  const sections = [];
1780
+ let hasSoulFile = false;
1525
1781
  for (const fileName of WORKSPACE_CONTEXT_FILE_ORDER) {
1526
1782
  const filePath = path.join(workspaceDir, fileName);
1527
1783
  let raw;
@@ -1533,6 +1789,7 @@ async function buildWorkspacePromptContext(workspaceDir) {
1533
1789
  const trimmed = raw.trim();
1534
1790
  if (!trimmed) continue;
1535
1791
  const clipped = truncateTextToMaxChars(trimmed, WORKSPACE_CONTEXT_MAX_PER_FILE);
1792
+ if (fileName.toLowerCase() === "soul.md") hasSoulFile = true;
1536
1793
  sections.push([
1537
1794
  `## ${fileName}`,
1538
1795
  clipped.text,
@@ -1540,7 +1797,14 @@ async function buildWorkspacePromptContext(workspaceDir) {
1540
1797
  ].filter(Boolean).join("\n\n"));
1541
1798
  }
1542
1799
  if (sections.length === 0) return "";
1543
- const rendered = ["# Workspace Context", ...sections].join("\n\n");
1800
+ const rendered = [
1801
+ "# Project Context",
1802
+ "",
1803
+ "The following project context files have been loaded:",
1804
+ hasSoulFile ? "If SOUL.md is present, embody its persona and tone unless higher-priority rules override it." : "",
1805
+ "",
1806
+ ...sections
1807
+ ].filter(Boolean).join("\n\n");
1544
1808
  if (rendered.length <= WORKSPACE_CONTEXT_MAX_TOTAL) return rendered;
1545
1809
  const footer = `\n\n[Workspace context truncated at ${WORKSPACE_CONTEXT_MAX_TOTAL} total characters.]`;
1546
1810
  if (footer.length >= WORKSPACE_CONTEXT_MAX_TOTAL) return rendered.slice(0, WORKSPACE_CONTEXT_MAX_TOTAL);
@@ -1549,7 +1813,7 @@ async function buildWorkspacePromptContext(workspaceDir) {
1549
1813
  }
1550
1814
  async function loadAgentPrompt(agentName, workspaceDir) {
1551
1815
  const promptPath = path.join(config.agentsDir, agentName, "CLAUDE.md");
1552
- let basePrompt = DEFAULT_SYSTEM_PROMPT;
1816
+ let basePrompt = buildDefaultSystemPrompt(workspaceDir);
1553
1817
  try {
1554
1818
  const prompt = await fs$1.readFile(promptPath, "utf8");
1555
1819
  if (prompt.trim().length > 0) basePrompt = prompt;
@@ -1576,14 +1840,14 @@ async function resolveProviderApiKey(provider, env = process.env) {
1576
1840
  const isAnthropicProvider = provider.toLowerCase() === "anthropic";
1577
1841
  const fromEnv = getProviderApiKeyFromEnv(provider, env);
1578
1842
  if (fromEnv) {
1579
- if (isAnthropicProvider) logAnthropicCredentialResolution("env", fromEnv);
1843
+ if (isAnthropicProvider) logAnthropicCredentialResolution("env");
1580
1844
  return fromEnv;
1581
1845
  }
1582
1846
  const credentials = loadCredentials(env);
1583
1847
  const entry = credentials[provider];
1584
1848
  if (!entry) return void 0;
1585
1849
  if (entry.type === "api-key") {
1586
- if (isAnthropicProvider) logAnthropicCredentialResolution("credentials-api-key", entry.key);
1850
+ if (isAnthropicProvider) logAnthropicCredentialResolution("credentials-api-key");
1587
1851
  return entry.key;
1588
1852
  }
1589
1853
  if (isAnthropicProvider && isAnthropicOAuthCredentialUnsupported(credentials.anthropic)) {
@@ -1591,7 +1855,7 @@ async function resolveProviderApiKey(provider, env = process.env) {
1591
1855
  return;
1592
1856
  }
1593
1857
  if (isAnthropicProvider && credentials.anthropic?.type === "oauth" && isAnthropicSetupToken(credentials.anthropic)) {
1594
- logAnthropicCredentialResolution("credentials-oauth-setup-token", credentials.anthropic.access);
1858
+ logAnthropicCredentialResolution("credentials-oauth-setup-token");
1595
1859
  return credentials.anthropic.access;
1596
1860
  }
1597
1861
  const result = await getOAuthApiKey(provider, toOAuthCredentialMap(credentials));
@@ -1607,7 +1871,7 @@ async function resolveProviderApiKey(provider, env = process.env) {
1607
1871
  ...result.newCredentials
1608
1872
  };
1609
1873
  saveCredentials(credentials, env);
1610
- if (isAnthropicProvider) logAnthropicCredentialResolution("oauth-refresh", result.apiKey);
1874
+ if (isAnthropicProvider) logAnthropicCredentialResolution("oauth-refresh");
1611
1875
  return result.apiKey;
1612
1876
  }
1613
1877
  var PiAgentManager = class {
@@ -1777,6 +2041,10 @@ var PiAgentManager = class {
1777
2041
  if (!handle) return;
1778
2042
  this.db.saveAgentState(sessionKey, JSON.stringify({ messages: handle.agent.state.messages }));
1779
2043
  }
2044
+ async resetSession(sessionKey) {
2045
+ this.sessions.delete(sessionKey);
2046
+ this.db.clearSession(sessionKey);
2047
+ }
1780
2048
  async persistAllSessions() {
1781
2049
  await Promise.all(Array.from(this.sessions.keys()).map((sessionKey) => this.persistSession(sessionKey)));
1782
2050
  }
@@ -1891,6 +2159,7 @@ const DEFAULT_MAX_RETRY_DELAY_MS = 8e3;
1891
2159
  const DEFAULT_POLL_SUCCESS_DELAY_MS = 250;
1892
2160
  const DEFAULT_POLL_FAILURE_DELAY_MS = 1e3;
1893
2161
  const DEFAULT_MAX_POLL_FAILURE_DELAY_MS = 15e3;
2162
+ const TELEGRAM_MAX_WEBHOOK_BODY_BYTES = 1048576;
1894
2163
  function sleep(ms) {
1895
2164
  return new Promise((resolve) => {
1896
2165
  setTimeout(resolve, ms);
@@ -2058,14 +2327,38 @@ function toInboundMessage(update, accountId = "default") {
2058
2327
  raw: update
2059
2328
  };
2060
2329
  }
2061
- async function readBody(req) {
2330
+ function compareWebhookSecrets(expected, provided) {
2331
+ const expectedBuffer = Buffer.from(expected, "utf8");
2332
+ const providedBuffer = Buffer.from(provided, "utf8");
2333
+ if (expectedBuffer.length !== providedBuffer.length) return false;
2334
+ return timingSafeEqual(expectedBuffer, providedBuffer);
2335
+ }
2336
+ async function readBody(req, maxBytes) {
2062
2337
  return new Promise((resolve, reject) => {
2063
2338
  let body = "";
2339
+ let totalBytes = 0;
2340
+ let done = false;
2064
2341
  req.on("data", (chunk) => {
2342
+ if (done) return;
2343
+ totalBytes += chunk.length;
2344
+ if (totalBytes > maxBytes) {
2345
+ done = true;
2346
+ req.destroy();
2347
+ reject(/* @__PURE__ */ new Error("payload_too_large"));
2348
+ return;
2349
+ }
2065
2350
  body += chunk.toString("utf8");
2066
2351
  });
2067
- req.on("end", () => resolve(body));
2068
- req.on("error", (error) => reject(error));
2352
+ req.on("end", () => {
2353
+ if (done) return;
2354
+ done = true;
2355
+ resolve(body);
2356
+ });
2357
+ req.on("error", (error) => {
2358
+ if (done) return;
2359
+ done = true;
2360
+ reject(error);
2361
+ });
2069
2362
  });
2070
2363
  }
2071
2364
  var TelegramChannel = class {
@@ -2261,14 +2554,25 @@ var TelegramChannel = class {
2261
2554
  }
2262
2555
  const expectedSecret = account.webhook.secret.trim();
2263
2556
  if (expectedSecret) {
2264
- if (String(req.headers["x-telegram-bot-api-secret-token"] || "") !== expectedSecret) {
2557
+ if (!compareWebhookSecrets(expectedSecret, String(req.headers["x-telegram-bot-api-secret-token"] || ""))) {
2265
2558
  res.statusCode = 403;
2266
2559
  this.lastWebhookStatus = 403;
2267
2560
  res.end("forbidden");
2268
2561
  return;
2269
2562
  }
2270
2563
  }
2271
- const body = await readBody(req);
2564
+ let body = "";
2565
+ try {
2566
+ body = await readBody(req, TELEGRAM_MAX_WEBHOOK_BODY_BYTES);
2567
+ } catch (error) {
2568
+ if (error instanceof Error && error.message === "payload_too_large") {
2569
+ res.statusCode = 413;
2570
+ this.lastWebhookStatus = 413;
2571
+ res.end("payload too large");
2572
+ return;
2573
+ }
2574
+ throw error;
2575
+ }
2272
2576
  if (!body) {
2273
2577
  res.statusCode = 204;
2274
2578
  this.lastWebhookStatus = 204;
@@ -2323,6 +2627,9 @@ var TelegramChannel = class {
2323
2627
  this.schedulePoll(10);
2324
2628
  logger.info({ accountId: this.accountId }, "Telegram polling channel started");
2325
2629
  }
2630
+ getAccountId() {
2631
+ return this.accountId;
2632
+ }
2326
2633
  onMessage(handler) {
2327
2634
  this.handler = handler;
2328
2635
  }
@@ -2360,6 +2667,21 @@ var TelegramChannel = class {
2360
2667
  text: text?.trim() || void 0
2361
2668
  });
2362
2669
  }
2670
+ async setMyCommands(commands) {
2671
+ const normalized = commands.map((entry) => ({
2672
+ command: entry.command,
2673
+ description: entry.description
2674
+ }));
2675
+ for (const scope of [
2676
+ { type: "default" },
2677
+ { type: "all_private_chats" },
2678
+ { type: "all_group_chats" },
2679
+ { type: "all_chat_administrators" }
2680
+ ]) await this.callApi("setMyCommands", {
2681
+ commands: normalized,
2682
+ scope
2683
+ });
2684
+ }
2363
2685
  async sendMedia(target, media) {
2364
2686
  const parsedTarget = parseTargetChatId(target.chatId);
2365
2687
  const { method, mediaField } = mediaMethod(media);
@@ -2548,6 +2870,313 @@ var ChannelPluginManager = class {
2548
2870
  }
2549
2871
  };
2550
2872
 
2873
+ //#endregion
2874
+ //#region src/cli/daemon.ts
2875
+ const DAEMON_STATE_FILE = "daemon-state.json";
2876
+ const DAEMON_LOG_FILE = "daemon.log";
2877
+ const LAUNCHD_LABEL = "ai.hovclaw.daemon";
2878
+ function ensureHomeDir(env = process.env) {
2879
+ const home = getHovclawHome(env);
2880
+ fs.mkdirSync(home, {
2881
+ recursive: true,
2882
+ mode: 448
2883
+ });
2884
+ return home;
2885
+ }
2886
+ function getStatePath(env = process.env) {
2887
+ return path.join(ensureHomeDir(env), DAEMON_STATE_FILE);
2888
+ }
2889
+ function getLogPath(env = process.env) {
2890
+ return path.join(ensureHomeDir(env), DAEMON_LOG_FILE);
2891
+ }
2892
+ function getUserHomeDir(env = process.env) {
2893
+ return env.HOME || os.homedir();
2894
+ }
2895
+ function getLaunchAgentsDir(env = process.env) {
2896
+ return path.join(getUserHomeDir(env), "Library", "LaunchAgents");
2897
+ }
2898
+ function getLaunchdPlistPath(env = process.env) {
2899
+ return path.join(getLaunchAgentsDir(env), `${LAUNCHD_LABEL}.plist`);
2900
+ }
2901
+ function getLaunchdDomain() {
2902
+ return `gui/${typeof process.getuid === "function" ? process.getuid() : 0}`;
2903
+ }
2904
+ function runLaunchctl(args, allowFailure = false) {
2905
+ const result = spawnSync("launchctl", args, { encoding: "utf8" });
2906
+ if (result.error) {
2907
+ if (!allowFailure) throw new Error(`launchctl ${args.join(" ")} failed: ${result.error.message}`);
2908
+ return {
2909
+ status: 1,
2910
+ stdout: result.stdout || "",
2911
+ stderr: result.stderr || result.error.message
2912
+ };
2913
+ }
2914
+ const status = result.status ?? 1;
2915
+ if (status !== 0 && !allowFailure) {
2916
+ const detail = (result.stderr || result.stdout || "unknown launchctl error").trim();
2917
+ throw new Error(`launchctl ${args.join(" ")} failed: ${detail}`);
2918
+ }
2919
+ return {
2920
+ status,
2921
+ stdout: result.stdout || "",
2922
+ stderr: result.stderr || ""
2923
+ };
2924
+ }
2925
+ function parseLaunchdRuntimeStatus(raw) {
2926
+ const stateMatch = raw.match(/^\s*state = (.+)$/m);
2927
+ const pidMatch = raw.match(/^\s*pid = (\d+)$/m);
2928
+ const lastExitCodeMatch = raw.match(/^\s*last exit code = (-?\d+)$/m);
2929
+ const stateRaw = stateMatch?.[1];
2930
+ const pidRaw = pidMatch?.[1];
2931
+ const lastExitCodeRaw = lastExitCodeMatch?.[1];
2932
+ const state = typeof stateRaw === "string" ? stateRaw.trim() : null;
2933
+ const pid = typeof pidRaw === "string" ? Number.parseInt(pidRaw, 10) : null;
2934
+ const lastExitCode = typeof lastExitCodeRaw === "string" ? Number.parseInt(lastExitCodeRaw, 10) : null;
2935
+ return {
2936
+ running: typeof pid === "number" && Number.isFinite(pid) && pid > 0 || state === "running",
2937
+ pid: typeof pid === "number" && Number.isFinite(pid) && pid > 0 ? pid : null,
2938
+ state,
2939
+ lastExitCode: typeof lastExitCode === "number" && Number.isFinite(lastExitCode) ? lastExitCode : null
2940
+ };
2941
+ }
2942
+ function getLaunchdRuntimeStatus(domain) {
2943
+ const output = runLaunchctl(["print", `${domain}/${LAUNCHD_LABEL}`], true);
2944
+ if (output.status !== 0) return {
2945
+ loaded: false,
2946
+ running: false,
2947
+ pid: null,
2948
+ state: null,
2949
+ lastExitCode: null
2950
+ };
2951
+ return {
2952
+ loaded: true,
2953
+ ...parseLaunchdRuntimeStatus(output.stdout)
2954
+ };
2955
+ }
2956
+ function getLaunchdStatus(env = process.env) {
2957
+ const domain = getLaunchdDomain();
2958
+ const plistPath = getLaunchdPlistPath(env);
2959
+ if (process.platform !== "darwin") return {
2960
+ supported: false,
2961
+ installed: false,
2962
+ loaded: false,
2963
+ running: false,
2964
+ pid: null,
2965
+ state: null,
2966
+ lastExitCode: null,
2967
+ plistPath,
2968
+ label: LAUNCHD_LABEL,
2969
+ domain
2970
+ };
2971
+ const installed = fs.existsSync(plistPath);
2972
+ const runtime = installed ? getLaunchdRuntimeStatus(domain) : {
2973
+ loaded: false,
2974
+ running: false,
2975
+ pid: null,
2976
+ state: null,
2977
+ lastExitCode: null
2978
+ };
2979
+ return {
2980
+ supported: true,
2981
+ installed,
2982
+ loaded: runtime.loaded,
2983
+ running: runtime.running,
2984
+ pid: runtime.pid,
2985
+ state: runtime.state,
2986
+ lastExitCode: runtime.lastExitCode,
2987
+ plistPath,
2988
+ label: LAUNCHD_LABEL,
2989
+ domain
2990
+ };
2991
+ }
2992
+ function saveState(state, env = process.env) {
2993
+ const statePath = getStatePath(env);
2994
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, {
2995
+ encoding: "utf8",
2996
+ mode: 384
2997
+ });
2998
+ }
2999
+ function clearState(env = process.env) {
3000
+ const statePath = getStatePath(env);
3001
+ if (fs.existsSync(statePath)) fs.rmSync(statePath, { force: true });
3002
+ }
3003
+ function resolveLaunchSpec() {
3004
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
3005
+ const jsEntryPath = path.resolve(cliDir, "../index.js");
3006
+ if (fs.existsSync(jsEntryPath)) return {
3007
+ command: process.execPath,
3008
+ args: [jsEntryPath],
3009
+ cwd: process.cwd()
3010
+ };
3011
+ const tsEntryPath = path.resolve(cliDir, "../index.ts");
3012
+ if (fs.existsSync(tsEntryPath)) return {
3013
+ command: process.execPath,
3014
+ args: [
3015
+ "--import",
3016
+ "tsx",
3017
+ tsEntryPath
3018
+ ],
3019
+ cwd: process.cwd()
3020
+ };
3021
+ throw new Error("Could not resolve daemon entrypoint (expected src/index.ts or dist/src/index.js).");
3022
+ }
3023
+ function shellEscape$1(value) {
3024
+ return `'${value.replace(/'/g, "'\\''")}'`;
3025
+ }
3026
+ function spawnDetachedDaemonWithDelay(launch, logPath, env, delayMs) {
3027
+ const outFd = fs.openSync(logPath, "a");
3028
+ const errFd = fs.openSync(logPath, "a");
3029
+ try {
3030
+ const delaySeconds = Math.max(0, delayMs) / 1e3;
3031
+ const command = [launch.command, ...launch.args].map((entry) => shellEscape$1(entry)).join(" ");
3032
+ const child = spawn("/bin/sh", ["-lc", `sleep ${delaySeconds.toFixed(3)}; exec ${command}`], {
3033
+ cwd: launch.cwd,
3034
+ env,
3035
+ detached: true,
3036
+ stdio: [
3037
+ "ignore",
3038
+ outFd,
3039
+ errFd
3040
+ ]
3041
+ });
3042
+ child.unref();
3043
+ if (child.pid === void 0) throw new Error("Failed to spawn replacement daemon process.");
3044
+ return child.pid;
3045
+ } finally {
3046
+ fs.closeSync(outFd);
3047
+ fs.closeSync(errFd);
3048
+ }
3049
+ }
3050
+ async function requestDaemonRestartFromCurrentProcess(env = process.env) {
3051
+ const launchd = getLaunchdStatus(env);
3052
+ if (launchd.supported && launchd.installed) {
3053
+ if (!launchd.loaded) runLaunchctl([
3054
+ "bootstrap",
3055
+ launchd.domain,
3056
+ launchd.plistPath
3057
+ ], false);
3058
+ runLaunchctl(["enable", `${launchd.domain}/${launchd.label}`], true);
3059
+ runLaunchctl([
3060
+ "kickstart",
3061
+ "-k",
3062
+ `${launchd.domain}/${launchd.label}`
3063
+ ], false);
3064
+ clearState(env);
3065
+ return {
3066
+ mode: "launchd-kickstart",
3067
+ pid: launchd.pid
3068
+ };
3069
+ }
3070
+ const launch = resolveLaunchSpec();
3071
+ const logPath = getLogPath(env);
3072
+ const pid = spawnDetachedDaemonWithDelay(launch, logPath, env, 350);
3073
+ saveState({
3074
+ pid,
3075
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3076
+ command: launch.command,
3077
+ args: launch.args,
3078
+ cwd: launch.cwd,
3079
+ logPath
3080
+ }, env);
3081
+ return {
3082
+ mode: "spawn-reexec",
3083
+ pid
3084
+ };
3085
+ }
3086
+
3087
+ //#endregion
3088
+ //#region src/compat/openclaw-mirror.ts
3089
+ function resolveOpenClawHome(env = process.env) {
3090
+ const override = env.OPENCLAW_STATE_DIR?.trim();
3091
+ if (override) return path.resolve(override.startsWith("~") ? path.join(os.homedir(), override.slice(1)) : override);
3092
+ return path.join(os.homedir(), ".openclaw");
3093
+ }
3094
+ function resolveOpenClawConfigPath(openclawHome) {
3095
+ return path.join(openclawHome, "openclaw.json");
3096
+ }
3097
+ function resolveOpenClawSharedSkillsPath(openclawHome) {
3098
+ return path.join(openclawHome, "skills");
3099
+ }
3100
+ function buildMirrorConfig(config) {
3101
+ const fallbackWorkspace = config.agents.defaults.workspace || path.join(config.hovclawHome, "workspace");
3102
+ const agentList = config.agents.list.length > 0 ? config.agents.list : [{
3103
+ id: "main",
3104
+ name: "Main",
3105
+ workspace: fallbackWorkspace,
3106
+ default: true
3107
+ }];
3108
+ const extraDirs = /* @__PURE__ */ new Set();
3109
+ extraDirs.add(config.skillsDir);
3110
+ for (const agent of agentList) {
3111
+ const workspace = (agent.workspace || fallbackWorkspace).trim();
3112
+ if (!workspace) continue;
3113
+ extraDirs.add(path.join(workspace, "skills"));
3114
+ }
3115
+ return {
3116
+ agent: { workspace: fallbackWorkspace },
3117
+ agents: {
3118
+ defaults: { workspace: fallbackWorkspace },
3119
+ list: agentList
3120
+ },
3121
+ skills: { load: { extraDirs: Array.from(extraDirs) } }
3122
+ };
3123
+ }
3124
+ function ensureDir(dirPath) {
3125
+ fs.mkdirSync(dirPath, {
3126
+ recursive: true,
3127
+ mode: 448
3128
+ });
3129
+ }
3130
+ function syncSkillsDir(sharedSkillsPath, sourceSkillsPath) {
3131
+ if (!fs.existsSync(sourceSkillsPath)) {
3132
+ ensureDir(sharedSkillsPath);
3133
+ return false;
3134
+ }
3135
+ try {
3136
+ if (fs.lstatSync(sharedSkillsPath).isSymbolicLink()) {
3137
+ const linkTarget = fs.readlinkSync(sharedSkillsPath);
3138
+ if (path.resolve(path.dirname(sharedSkillsPath), linkTarget) === sourceSkillsPath) return true;
3139
+ fs.unlinkSync(sharedSkillsPath);
3140
+ }
3141
+ } catch {}
3142
+ if (!fs.existsSync(sharedSkillsPath)) try {
3143
+ fs.symlinkSync(sourceSkillsPath, sharedSkillsPath, "dir");
3144
+ return true;
3145
+ } catch {}
3146
+ ensureDir(sharedSkillsPath);
3147
+ for (const entry of fs.readdirSync(sourceSkillsPath, { withFileTypes: true })) {
3148
+ const src = path.join(sourceSkillsPath, entry.name);
3149
+ const dst = path.join(sharedSkillsPath, entry.name);
3150
+ if (entry.isDirectory()) {
3151
+ fs.cpSync(src, dst, {
3152
+ recursive: true,
3153
+ force: true
3154
+ });
3155
+ continue;
3156
+ }
3157
+ if (entry.isFile()) fs.copyFileSync(src, dst);
3158
+ }
3159
+ return false;
3160
+ }
3161
+ function writeOpenClawMirror(config) {
3162
+ const openclawHome = resolveOpenClawHome();
3163
+ const configPath = resolveOpenClawConfigPath(openclawHome);
3164
+ const sharedSkillsPath = resolveOpenClawSharedSkillsPath(openclawHome);
3165
+ ensureDir(openclawHome);
3166
+ const mirrorConfig = buildMirrorConfig(config);
3167
+ fs.writeFileSync(configPath, `${JSON.stringify(mirrorConfig, null, 2)}\n`, {
3168
+ encoding: "utf8",
3169
+ mode: 384
3170
+ });
3171
+ fs.chmodSync(configPath, 384);
3172
+ return {
3173
+ openclawHome,
3174
+ configPath,
3175
+ sharedSkillsPath,
3176
+ linkedSharedSkills: syncSkillsDir(sharedSkillsPath, config.skillsDir)
3177
+ };
3178
+ }
3179
+
2551
3180
  //#endregion
2552
3181
  //#region src/models/catalog.ts
2553
3182
  function buildModelCatalog(aliases) {
@@ -2569,98 +3198,240 @@ function buildModelCatalog(aliases) {
2569
3198
  }
2570
3199
 
2571
3200
  //#endregion
2572
- //#region src/channels/telegram-policy.ts
2573
- function toIdSet(values) {
2574
- return new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean));
2575
- }
2576
- function splitThread(chatId) {
2577
- const [baseChatIdRaw, threadIdRaw] = chatId.split("#");
2578
- return {
2579
- baseChatId: baseChatIdRaw ?? chatId,
2580
- ...threadIdRaw ? { threadId: threadIdRaw } : {}
2581
- };
2582
- }
2583
- function isDirectMessage(msg) {
2584
- return msg.peer?.kind === "direct" || !msg.chatId.startsWith("-") && !msg.chatId.includes("#");
3201
+ //#region src/channels/command-auth.ts
3202
+ function normalizeAllowFromEntry(value) {
3203
+ return String(value).trim().toLowerCase();
3204
+ }
3205
+ function senderCandidates(msg) {
3206
+ const candidates = /* @__PURE__ */ new Set();
3207
+ const userId = msg.userId.trim();
3208
+ if (userId) candidates.add(userId.toLowerCase());
3209
+ const displayName = msg.displayName.trim();
3210
+ if (displayName.startsWith("@")) candidates.add(displayName.toLowerCase());
3211
+ return Array.from(candidates);
3212
+ }
3213
+ function resolveCommandsAllowFrom(config, channel) {
3214
+ const entries = config.commands.allowFrom;
3215
+ if (Object.keys(entries).length === 0) return null;
3216
+ const providerSpecific = entries[channel];
3217
+ if (Array.isArray(providerSpecific)) return providerSpecific;
3218
+ const global = entries["*"];
3219
+ if (Array.isArray(global)) return global;
3220
+ return [];
3221
+ }
3222
+ function isCommandAuthorized(config, msg) {
3223
+ const allowFrom = resolveCommandsAllowFrom(config, msg.channel);
3224
+ if (allowFrom === null) return true;
3225
+ const normalizedAllow = new Set(allowFrom.map((entry) => normalizeAllowFromEntry(entry)));
3226
+ if (normalizedAllow.has("*")) return true;
3227
+ for (const candidate of senderCandidates(msg)) if (normalizedAllow.has(candidate)) return true;
3228
+ return false;
2585
3229
  }
2586
- function containsMention(text, assistantName) {
2587
- const normalized = assistantName.trim().toLowerCase();
2588
- if (!normalized) return false;
2589
- return text.toLowerCase().includes(`@${normalized}`);
3230
+
3231
+ //#endregion
3232
+ //#region src/channels/commands-registry.ts
3233
+ const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
3234
+ const TELEGRAM_COMMAND_LIMIT = 100;
3235
+ const BASE_COMMANDS = [
3236
+ {
3237
+ key: "help",
3238
+ nativeName: "help",
3239
+ description: "Show available commands"
3240
+ },
3241
+ {
3242
+ key: "commands",
3243
+ nativeName: "commands",
3244
+ description: "List all slash commands"
3245
+ },
3246
+ {
3247
+ key: "status",
3248
+ nativeName: "status",
3249
+ description: "Show runtime status"
3250
+ },
3251
+ {
3252
+ key: "whoami",
3253
+ nativeName: "whoami",
3254
+ description: "Show your sender id"
3255
+ },
3256
+ {
3257
+ key: "context",
3258
+ nativeName: "context",
3259
+ description: "Show prompt/workspace context info"
3260
+ },
3261
+ {
3262
+ key: "model",
3263
+ nativeName: "model",
3264
+ description: "Show or set interactive model"
3265
+ },
3266
+ {
3267
+ key: "models",
3268
+ nativeName: "models",
3269
+ description: "List available models"
3270
+ },
3271
+ {
3272
+ key: "skill",
3273
+ nativeName: "skill",
3274
+ description: "Run a skill by name"
3275
+ },
3276
+ {
3277
+ key: "skills",
3278
+ nativeName: "skills",
3279
+ description: "List installed skills"
3280
+ },
3281
+ {
3282
+ key: "identity",
3283
+ nativeName: "identity",
3284
+ description: "View or update IDENTITY.md"
3285
+ },
3286
+ {
3287
+ key: "soul",
3288
+ nativeName: "soul",
3289
+ description: "View or update SOUL.md"
3290
+ },
3291
+ {
3292
+ key: "pair",
3293
+ nativeName: "pair",
3294
+ description: "Show pairing approval hint"
3295
+ },
3296
+ {
3297
+ key: "new",
3298
+ nativeName: "new",
3299
+ description: "Start a fresh conversation session"
3300
+ },
3301
+ {
3302
+ key: "reset",
3303
+ nativeName: "reset",
3304
+ description: "Reset current conversation session"
3305
+ },
3306
+ {
3307
+ key: "think",
3308
+ nativeName: "think",
3309
+ description: "Set per-task or default thinking level"
3310
+ },
3311
+ {
3312
+ key: "verbose",
3313
+ nativeName: "verbose",
3314
+ description: "Toggle verbose mode (hint)"
3315
+ },
3316
+ {
3317
+ key: "reasoning",
3318
+ nativeName: "reasoning",
3319
+ description: "Toggle reasoning visibility (hint)"
3320
+ },
3321
+ {
3322
+ key: "stop",
3323
+ nativeName: "stop",
3324
+ description: "Stop current run (not yet supported)"
3325
+ },
3326
+ {
3327
+ key: "config",
3328
+ nativeName: "config",
3329
+ description: "View or edit runtime config (if enabled)"
3330
+ },
3331
+ {
3332
+ key: "debug",
3333
+ nativeName: "debug",
3334
+ description: "Inspect or change debug state (if enabled)"
3335
+ },
3336
+ {
3337
+ key: "bash",
3338
+ nativeName: "bash",
3339
+ description: "Run a shell command through the assistant (if enabled)"
3340
+ },
3341
+ {
3342
+ key: "restart",
3343
+ nativeName: "restart",
3344
+ description: "Restart daemon (if enabled)"
3345
+ }
3346
+ ];
3347
+ function sanitizeCommandName(raw) {
3348
+ return raw.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32);
3349
+ }
3350
+ function coerceDescription(input, fallback) {
3351
+ const trimmed = input.trim();
3352
+ if (!trimmed) return fallback;
3353
+ if (trimmed.length <= 256) return trimmed;
3354
+ return `${trimmed.slice(0, 255)}…`;
3355
+ }
3356
+ function resolveSkillCommandName(skillName, used) {
3357
+ const base = sanitizeCommandName(skillName) || "skill";
3358
+ if (!used.has(base)) return base;
3359
+ for (let i = 2; i < 500; i += 1) {
3360
+ const suffix = `_${i}`;
3361
+ const maxBaseLen = Math.max(1, 32 - suffix.length);
3362
+ const candidate = `${base.slice(0, maxBaseLen)}${suffix}`;
3363
+ if (!used.has(candidate)) return candidate;
3364
+ }
3365
+ return `skill_${used.size}`;
3366
+ }
3367
+ function resolveNativeEnabled(config, accountId) {
3368
+ const accountMode = config.channels.telegram.accounts[accountId]?.commands;
3369
+ if (typeof accountMode === "boolean") return accountMode;
3370
+ if (typeof config.commands.native === "boolean") return config.commands.native;
3371
+ return true;
2590
3372
  }
2591
- function isAllowListed(msg, allowSet) {
2592
- if (allowSet.has("*")) return true;
2593
- const userId = msg.userId.trim().toLowerCase();
2594
- if (userId && allowSet.has(userId)) return true;
2595
- const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
2596
- if (displayName && allowSet.has(displayName)) return true;
2597
- return false;
3373
+ function resolveNativeSkillsEnabled(config) {
3374
+ if (typeof config.commands.nativeSkills === "boolean") return config.commands.nativeSkills;
3375
+ return true;
2598
3376
  }
2599
- function evaluateTelegramPolicy(params) {
2600
- const { config, msg, pairingStore } = params;
2601
- const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
2602
- const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
2603
- if (!account || account.enabled === false) return {
2604
- allowed: false,
2605
- reason: "account-disabled"
2606
- };
2607
- const direct = isDirectMessage(msg);
2608
- const allowFromSet = toIdSet(account.allowFrom);
2609
- if (direct) {
2610
- const dmPolicy = account.dmPolicy ?? "pairing";
2611
- if (dmPolicy === "disabled") return {
2612
- allowed: false,
2613
- reason: "dm-disabled"
2614
- };
2615
- if (dmPolicy === "open") return { allowed: true };
2616
- if (dmPolicy === "allowlist") {
2617
- if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
2618
- return {
2619
- allowed: false,
2620
- reason: "dm-not-allowlisted"
2621
- };
2622
- }
2623
- if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
2624
- return {
2625
- allowed: false,
2626
- reason: "pairing-required",
2627
- pairingCode: pairingStore.ensurePendingCode(accountId, msg.userId)
2628
- };
3377
+ function listSkillCommandSpecs(config) {
3378
+ const installed = listAvailableSkills();
3379
+ const used = /* @__PURE__ */ new Set();
3380
+ for (const command of BASE_COMMANDS) used.add(command.nativeName);
3381
+ const specs = [];
3382
+ for (const skillName of installed) {
3383
+ const loaded = loadSkill(skillName);
3384
+ if (!loaded) continue;
3385
+ const commandName = resolveSkillCommandName(skillName, used);
3386
+ if (!TELEGRAM_COMMAND_NAME_RE.test(commandName)) continue;
3387
+ used.add(commandName);
3388
+ specs.push({
3389
+ name: commandName,
3390
+ skillName,
3391
+ description: coerceDescription(loaded.frontmatter.description ?? `Run ${skillName} skill`, `Run ${skillName} skill`)
3392
+ });
2629
3393
  }
2630
- const { baseChatId, threadId } = splitThread(msg.chatId);
2631
- const groupConfig = account.groups?.[baseChatId];
2632
- const topicConfig = threadId ? groupConfig?.topics?.[threadId] : void 0;
2633
- if (groupConfig?.enabled === false) return {
2634
- allowed: false,
2635
- reason: "group-disabled"
2636
- };
2637
- if (topicConfig?.enabled === false) return {
2638
- allowed: false,
2639
- reason: "topic-disabled"
2640
- };
2641
- const groupPolicy = topicConfig?.groupPolicy ?? groupConfig?.groupPolicy ?? account.groupPolicy ?? "open";
2642
- if (groupPolicy === "disabled") return {
2643
- allowed: false,
2644
- reason: "group-disabled"
2645
- };
2646
- if (groupPolicy === "allowlist") {
2647
- if (!isAllowListed(msg, toIdSet(topicConfig?.allowFrom ?? groupConfig?.allowFrom ?? account.groupAllowFrom))) return {
2648
- allowed: false,
2649
- reason: "group-not-allowlisted"
2650
- };
3394
+ return specs;
3395
+ }
3396
+ function listCustomCommandSpecs(config, accountId) {
3397
+ const account = config.channels.telegram.accounts[accountId];
3398
+ if (!account) return [];
3399
+ const commands = Array.isArray(account.customCommands) ? account.customCommands : [];
3400
+ const out = [];
3401
+ for (const entry of commands) {
3402
+ const normalized = sanitizeCommandName(entry.command);
3403
+ if (!TELEGRAM_COMMAND_NAME_RE.test(normalized)) continue;
3404
+ out.push({
3405
+ command: normalized,
3406
+ description: coerceDescription(entry.description, `Run /${normalized}`)
3407
+ });
2651
3408
  }
2652
- if ((topicConfig?.requireMention ?? groupConfig?.requireMention ?? false) && !containsMention(msg.text, config.assistantName)) return {
2653
- allowed: false,
2654
- reason: "mention-required"
2655
- };
2656
- return { allowed: true };
3409
+ return out;
2657
3410
  }
2658
- function canApproveTelegramPairing(params) {
2659
- const { config, msg } = params;
2660
- const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
2661
- const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
2662
- if (!account) return false;
2663
- return isAllowListed(msg, toIdSet(account.allowFrom));
3411
+ function listTelegramNativeCommandSpecs(config, accountId) {
3412
+ if (!resolveNativeEnabled(config, accountId)) return [];
3413
+ const out = BASE_COMMANDS.map((command) => ({
3414
+ command: command.nativeName,
3415
+ description: command.description
3416
+ }));
3417
+ if (resolveNativeSkillsEnabled(config)) out.push(...listSkillCommandSpecs(config).map((skill) => ({
3418
+ command: skill.name,
3419
+ description: skill.description
3420
+ })));
3421
+ out.push(...listCustomCommandSpecs(config, accountId));
3422
+ const deduped = [];
3423
+ const seen = /* @__PURE__ */ new Set();
3424
+ for (const entry of out) {
3425
+ if (!TELEGRAM_COMMAND_NAME_RE.test(entry.command)) continue;
3426
+ if (seen.has(entry.command)) continue;
3427
+ seen.add(entry.command);
3428
+ deduped.push(entry);
3429
+ if (deduped.length >= TELEGRAM_COMMAND_LIMIT) break;
3430
+ }
3431
+ return deduped;
3432
+ }
3433
+ function listBaseCommands() {
3434
+ return [...BASE_COMMANDS];
2664
3435
  }
2665
3436
 
2666
3437
  //#endregion
@@ -2751,11 +3522,28 @@ function buildModelsKeyboard(params) {
2751
3522
 
2752
3523
  //#endregion
2753
3524
  //#region src/channels/telegram-commands.ts
3525
+ const SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
3526
+ const DEBUG_LOG_LEVELS = new Set([
3527
+ "trace",
3528
+ "debug",
3529
+ "info",
3530
+ "warn",
3531
+ "error",
3532
+ "fatal",
3533
+ "silent"
3534
+ ]);
3535
+ const BLOCKED_CONFIG_PATH_SEGMENTS = new Set([
3536
+ "__proto__",
3537
+ "prototype",
3538
+ "constructor"
3539
+ ]);
2754
3540
  function extractCommand(text) {
2755
3541
  const trimmed = text.trim();
2756
3542
  if (!trimmed.startsWith("/")) return null;
2757
3543
  const segments = trimmed.split(/\s+/);
2758
- const command = segments[0]?.slice(1).toLowerCase();
3544
+ const raw = segments[0]?.slice(1).toLowerCase();
3545
+ if (!raw) return null;
3546
+ const command = raw.split("@")[0]?.trim();
2759
3547
  if (!command) return null;
2760
3548
  return {
2761
3549
  command,
@@ -2770,8 +3558,125 @@ function callbackDataFromMessage(msg) {
2770
3558
  function setInteractiveModel(modelRef) {
2771
3559
  const next = loadFileConfig();
2772
3560
  next.models.interactive = modelRef;
3561
+ persistFileConfig(next);
3562
+ }
3563
+ function isRecord$1(value) {
3564
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3565
+ }
3566
+ function deepMerge$1(base, patch) {
3567
+ const result = { ...base };
3568
+ for (const [key, value] of Object.entries(patch)) {
3569
+ const existing = result[key];
3570
+ if (isRecord$1(existing) && isRecord$1(value)) {
3571
+ result[key] = deepMerge$1(existing, value);
3572
+ continue;
3573
+ }
3574
+ result[key] = value;
3575
+ }
3576
+ return result;
3577
+ }
3578
+ function reloadRuntimeConfig() {
3579
+ const reloaded = loadConfig();
3580
+ Object.assign(config, reloaded);
3581
+ logger.level = reloaded.logLevel;
3582
+ }
3583
+ function persistFileConfig(next) {
2773
3584
  saveConfigFile(next);
2774
- config.models.interactive = modelRef;
3585
+ reloadRuntimeConfig();
3586
+ writeOpenClawMirror(config);
3587
+ }
3588
+ function parseConfigPath(raw) {
3589
+ const pathValue = raw?.trim();
3590
+ if (!pathValue) return null;
3591
+ const segments = pathValue.split(".").map((segment) => segment.trim()).filter(Boolean);
3592
+ if (segments.length === 0) return null;
3593
+ if (segments.some((segment) => BLOCKED_CONFIG_PATH_SEGMENTS.has(segment))) return null;
3594
+ return segments;
3595
+ }
3596
+ function readPathValue(root, segments) {
3597
+ let current = root;
3598
+ for (const segment of segments) {
3599
+ if (Array.isArray(current)) {
3600
+ const index = Number.parseInt(segment, 10);
3601
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false };
3602
+ current = current[index];
3603
+ continue;
3604
+ }
3605
+ if (!isRecord$1(current) || !(segment in current)) return { found: false };
3606
+ current = current[segment];
3607
+ }
3608
+ return {
3609
+ found: true,
3610
+ value: current
3611
+ };
3612
+ }
3613
+ function writePathValue(root, segments, value) {
3614
+ if (segments.length === 0) return false;
3615
+ let current = root;
3616
+ for (let i = 0; i < segments.length - 1; i += 1) {
3617
+ const segment = segments[i];
3618
+ const nextSegment = segments[i + 1];
3619
+ if (!segment || !nextSegment) return false;
3620
+ if (Array.isArray(current)) {
3621
+ const index = Number.parseInt(segment, 10);
3622
+ if (!Number.isInteger(index) || index < 0) return false;
3623
+ if (current[index] === void 0) current[index] = /^[0-9]+$/.test(nextSegment) ? [] : {};
3624
+ current = current[index];
3625
+ continue;
3626
+ }
3627
+ if (!isRecord$1(current)) return false;
3628
+ const existing = current[segment];
3629
+ if (existing === void 0 || !isRecord$1(existing) && !Array.isArray(existing)) current[segment] = /^[0-9]+$/.test(nextSegment) ? [] : {};
3630
+ current = current[segment];
3631
+ }
3632
+ const lastSegment = segments[segments.length - 1];
3633
+ if (!lastSegment) return false;
3634
+ if (Array.isArray(current)) {
3635
+ const index = Number.parseInt(lastSegment, 10);
3636
+ if (!Number.isInteger(index) || index < 0) return false;
3637
+ current[index] = value;
3638
+ return true;
3639
+ }
3640
+ if (!isRecord$1(current)) return false;
3641
+ current[lastSegment] = value;
3642
+ return true;
3643
+ }
3644
+ function parseConfigValue(raw) {
3645
+ const trimmed = raw.trim();
3646
+ if (!trimmed) return "";
3647
+ try {
3648
+ return JSON.parse(trimmed);
3649
+ } catch {
3650
+ return raw;
3651
+ }
3652
+ }
3653
+ function formatValue(value) {
3654
+ if (typeof value === "string") return value;
3655
+ return JSON.stringify(value, null, 2);
3656
+ }
3657
+ function configSummaryText(current) {
3658
+ return [
3659
+ "Config summary",
3660
+ `assistant.name=${current.assistant.name}`,
3661
+ `models.interactive=${current.models.interactive}`,
3662
+ `models.discord=${current.models.discord}`,
3663
+ `models.cron=${current.models.cron}`,
3664
+ `runtime.mode=${current.runtime.mode}`,
3665
+ `commands.text=${current.commands.text}`,
3666
+ `commands.defaultThinkingLevel=${current.commands.defaultThinkingLevel}`,
3667
+ `commands.config=${current.commands.config}`,
3668
+ `commands.debug=${current.commands.debug}`,
3669
+ `commands.bash=${current.commands.bash}`,
3670
+ `commands.restart=${current.commands.restart}`,
3671
+ `channels.telegram.enabled=${current.channels.telegram.enabled}`,
3672
+ `channels.discord.enabled=${current.channels.discord.enabled}`,
3673
+ "",
3674
+ "Usage:",
3675
+ "/config get <path>",
3676
+ "/config set <path> <json-or-text>",
3677
+ "/config patch <json-object>",
3678
+ "/config reload"
3679
+ ].join("\n");
2775
3680
  }
2776
3681
  function groupedModels() {
2777
3682
  return buildModelCatalog(config.models.aliases).reduce((acc, entry) => {
@@ -2781,9 +3686,57 @@ function groupedModels() {
2781
3686
  return acc;
2782
3687
  }, {});
2783
3688
  }
3689
+ function workspaceFilePath(agentId, fileName) {
3690
+ return path.join(resolveAgentWorkspaceDir(config, agentId), fileName);
3691
+ }
3692
+ async function readWorkspaceFileOrEmpty(agentId, fileName) {
3693
+ const filePath = workspaceFilePath(agentId, fileName);
3694
+ try {
3695
+ return await fs$1.readFile(filePath, "utf8");
3696
+ } catch {
3697
+ return "";
3698
+ }
3699
+ }
3700
+ async function writeWorkspaceFile(agentId, fileName, content) {
3701
+ const filePath = workspaceFilePath(agentId, fileName);
3702
+ await fs$1.mkdir(path.dirname(filePath), { recursive: true });
3703
+ await fs$1.writeFile(filePath, `${content.trimEnd()}\n`, "utf8");
3704
+ }
3705
+ function trimForTelegram(text, limit = 3600) {
3706
+ if (text.length <= limit) return text;
3707
+ return `${text.slice(0, limit)}\n\n[...truncated]`;
3708
+ }
3709
+ async function addSkillToAgent(agentId, skillName) {
3710
+ const agentDir = path.join(config.agentsDir, agentId);
3711
+ const agentPath = path.join(agentDir, "agent.json");
3712
+ let parsed = {};
3713
+ try {
3714
+ const raw = await fs$1.readFile(agentPath, "utf8");
3715
+ parsed = JSON.parse(raw);
3716
+ } catch {
3717
+ parsed = {};
3718
+ }
3719
+ const existingSkills = Array.isArray(parsed.skills) ? parsed.skills.filter((value) => typeof value === "string") : [];
3720
+ if (existingSkills.includes(skillName)) return {
3721
+ added: false,
3722
+ path: agentPath
3723
+ };
3724
+ const nextSkills = [...existingSkills, skillName];
3725
+ const next = {
3726
+ ...parsed,
3727
+ skills: nextSkills
3728
+ };
3729
+ await fs$1.mkdir(agentDir, { recursive: true });
3730
+ await fs$1.writeFile(agentPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
3731
+ return {
3732
+ added: true,
3733
+ path: agentPath
3734
+ };
3735
+ }
2784
3736
  async function handleModelCallback(context, callbackData) {
2785
3737
  const parsed = parseModelCallbackData(callbackData);
2786
3738
  if (!parsed) return false;
3739
+ const authorized = isCommandAuthorized(config, context.msg);
2787
3740
  const grouped = groupedModels();
2788
3741
  const target = {
2789
3742
  channel: "telegram",
@@ -2815,6 +3768,12 @@ async function handleModelCallback(context, callbackData) {
2815
3768
  if (callbackId) await context.channel.answerCallbackQuery(callbackId);
2816
3769
  return true;
2817
3770
  }
3771
+ if (!authorized) {
3772
+ await context.channel.sendMessage(target, "You are not authorized to use this command.");
3773
+ const callbackId = context.msg.raw?.callback_query?.id;
3774
+ if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Not authorized");
3775
+ return true;
3776
+ }
2818
3777
  const selectedRef = `${parsed.provider}:${parsed.model}`;
2819
3778
  setInteractiveModel(selectedRef);
2820
3779
  await context.channel.sendMessage(target, `Interactive model set to ${selectedRef}.`);
@@ -2822,111 +3781,496 @@ async function handleModelCallback(context, callbackData) {
2822
3781
  if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Model updated");
2823
3782
  return true;
2824
3783
  }
3784
+ function buildCommandsHelpText(accountId) {
3785
+ const nativeCommands = listTelegramNativeCommandSpecs(config, accountId);
3786
+ const fallbackCommands = [...listBaseCommands().map((command) => ({
3787
+ command: command.nativeName,
3788
+ description: command.description
3789
+ })), ...listSkillCommandSpecs(config).map((command) => ({
3790
+ command: command.name,
3791
+ description: command.description
3792
+ }))];
3793
+ return ["Commands:", ...(nativeCommands.length > 0 ? nativeCommands : fallbackCommands).map((command) => `/${command.command} - ${command.description}`)].join("\n");
3794
+ }
3795
+ function resolveSkillPrompt(skillName, args) {
3796
+ const input = args.join(" ").trim();
3797
+ if (!input) return `Use the ${skillName} skill to help with the user's request.`;
3798
+ return `Use the ${skillName} skill for this request: ${input}`;
3799
+ }
3800
+ function normalizeSkillName(raw) {
3801
+ const name = raw?.trim();
3802
+ if (!name) return null;
3803
+ if (!SKILL_NAME_RE.test(name)) return null;
3804
+ return name;
3805
+ }
3806
+ function parseThinkingLevel(raw) {
3807
+ const value = raw?.trim().toLowerCase();
3808
+ if (value === "low" || value === "medium" || value === "high") return value;
3809
+ return null;
3810
+ }
2825
3811
  async function handleTelegramCommand(context) {
2826
3812
  const callbackData = callbackDataFromMessage(context.msg);
2827
- if (callbackData) return await handleModelCallback(context, callbackData);
3813
+ if (callbackData) return { handled: await handleModelCallback(context, callbackData) };
2828
3814
  const parsed = extractCommand(context.msg.text);
2829
- if (!parsed) return false;
3815
+ if (!parsed) return { handled: false };
3816
+ if (!config.commands.text) return { handled: false };
2830
3817
  const target = {
2831
3818
  channel: "telegram",
2832
3819
  chatId: context.msg.chatId,
2833
3820
  accountId: context.msg.accountId
2834
3821
  };
2835
- if (parsed.command === "help") {
2836
- await context.channel.sendMessage(target, [
2837
- "Commands:",
2838
- "/help",
2839
- "/status",
2840
- "/models",
2841
- "/model [provider:model]",
2842
- "/pair approve <code>",
2843
- "/pair reject <code>"
2844
- ].join("\n"));
2845
- return true;
3822
+ const authorized = isCommandAuthorized(config, context.msg);
3823
+ const allowWithoutAuth = parsed.command === "help" || parsed.command === "commands";
3824
+ if (!authorized && !allowWithoutAuth) {
3825
+ await context.channel.sendMessage(target, "You are not authorized to use this command.");
3826
+ return { handled: true };
3827
+ }
3828
+ if (parsed.command === "help" || parsed.command === "commands") {
3829
+ const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
3830
+ await context.channel.sendMessage(target, buildCommandsHelpText(accountId));
3831
+ return { handled: true };
2846
3832
  }
2847
3833
  if (parsed.command === "status") {
2848
3834
  const diagnostics = context.channel.getDiagnostics?.() ?? {};
2849
3835
  await context.channel.sendMessage(target, `Status\nactiveSessions=${context.db.listSessions().length}\n${JSON.stringify(diagnostics)}`);
2850
- return true;
3836
+ return { handled: true };
3837
+ }
3838
+ if (parsed.command === "whoami") {
3839
+ const lines = [
3840
+ "Identity",
3841
+ `channel=${context.msg.channel}`,
3842
+ `chatId=${context.msg.chatId}`,
3843
+ `userId=${context.msg.userId}`,
3844
+ `displayName=${context.msg.displayName}`,
3845
+ `agent=${context.agentId}`
3846
+ ];
3847
+ await context.channel.sendMessage(target, lines.join("\n"));
3848
+ return { handled: true };
3849
+ }
3850
+ if (parsed.command === "context") {
3851
+ const workspaceDir = resolveAgentWorkspaceDir(config, context.agentId);
3852
+ const lines = [
3853
+ `Agent: ${context.agentId}`,
3854
+ `Workspace: ${workspaceDir}`,
3855
+ "Context files:",
3856
+ ...WORKSPACE_CONTEXT_FILE_ORDER.map((file) => `- ${file}`)
3857
+ ];
3858
+ await context.channel.sendMessage(target, lines.join("\n"));
3859
+ return { handled: true };
2851
3860
  }
2852
3861
  if (parsed.command === "models") {
2853
- const lines = buildModelCatalog(config.models.aliases).slice(0, 80).map((entry) => `- ${entry.ref}${entry.alias ? ` (alias: ${entry.alias})` : ""}`);
3862
+ const lines = buildModelCatalog(config.models.aliases).slice(0, 100).map((entry) => `- ${entry.ref}${entry.alias ? ` (alias: ${entry.alias})` : ""}`);
2854
3863
  await context.channel.sendMessage(target, lines.length > 0 ? lines.join("\n") : "No models found.");
2855
- return true;
3864
+ return { handled: true };
2856
3865
  }
2857
3866
  if (parsed.command === "model") {
2858
- const modelArg = parsed.args[0]?.trim();
3867
+ const modelArg = parsed.args.join(" ").trim();
2859
3868
  if (modelArg) {
2860
3869
  setInteractiveModel(modelArg);
2861
3870
  await context.channel.sendMessage(target, `Interactive model set to ${modelArg}.`);
2862
- return true;
3871
+ return { handled: true };
2863
3872
  }
2864
3873
  const providerEntries = Object.entries(groupedModels()).map(([id, models]) => ({
2865
3874
  id,
2866
3875
  count: models.length
2867
3876
  }));
2868
3877
  await context.channel.sendRichMessage(target, `Current interactive model: ${config.models.interactive}`, { inlineKeyboard: providerEntries.length > 0 ? buildProviderKeyboard(providerEntries) : buildBrowseProvidersButton() });
2869
- return true;
3878
+ return { handled: true };
2870
3879
  }
2871
3880
  if (parsed.command === "pair") {
2872
- if (!canApproveTelegramPairing({
2873
- config,
2874
- msg: context.msg
2875
- })) {
2876
- await context.channel.sendMessage(target, "Not authorized to approve pairing requests.");
2877
- return true;
2878
- }
2879
- const action = parsed.args[0]?.toLowerCase();
2880
- const code = parsed.args[1]?.trim().toUpperCase();
2881
3881
  const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
2882
- if (!action || !code) {
2883
- await context.channel.sendMessage(target, "Usage: /pair approve <code> or /pair reject <code>");
2884
- return true;
3882
+ const code = (parsed.args[1] ?? parsed.args[0])?.trim().toUpperCase() || "<code>";
3883
+ await context.channel.sendMessage(target, `Pair approvals are CLI-only. Run: hovclaw pairing approve --channel telegram --account ${accountId} ${code}`);
3884
+ return { handled: true };
3885
+ }
3886
+ if (parsed.command === "skills") {
3887
+ const skills = listAvailableSkills();
3888
+ if (skills.length === 0) {
3889
+ await context.channel.sendMessage(target, "No installed skills found.");
3890
+ return { handled: true };
2885
3891
  }
2886
- if (action === "approve") {
2887
- const result = context.pairingStore.approveByCode(accountId, code);
2888
- if (!result.ok) await context.channel.sendMessage(target, `Pairing code not found: ${code}`);
2889
- else {
2890
- context.db.appendAuditEvent({
2891
- actor: "channel",
2892
- eventType: "telegram.pair.approve",
2893
- payload: {
2894
- accountId,
2895
- code,
2896
- userId: result.userId,
2897
- approver: context.msg.userId
2898
- }
2899
- });
2900
- await context.channel.sendMessage(target, `Approved pairing for user ${result.userId}.`);
3892
+ const lines = skills.slice(0, 40).map((name) => {
3893
+ return `- ${name}: ${loadSkill(name)?.frontmatter.description?.trim() || "No description"}`;
3894
+ });
3895
+ await context.channel.sendMessage(target, trimForTelegram(lines.join("\n")));
3896
+ return { handled: true };
3897
+ }
3898
+ if (parsed.command === "identity" || parsed.command === "soul") {
3899
+ const fileName = parsed.command === "identity" ? "IDENTITY.md" : "SOUL.md";
3900
+ const subcommand = parsed.args[0]?.toLowerCase();
3901
+ const body = parsed.args.slice(1).join(" ").trim();
3902
+ if (!subcommand || subcommand === "view") {
3903
+ const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
3904
+ if (!current.trim()) {
3905
+ await context.channel.sendMessage(target, `${fileName} is empty.`);
3906
+ return { handled: true };
2901
3907
  }
2902
- return true;
3908
+ await context.channel.sendMessage(target, trimForTelegram(current));
3909
+ return { handled: true };
2903
3910
  }
2904
- if (action === "reject") {
2905
- const result = context.pairingStore.rejectByCode(accountId, code);
2906
- if (!result.ok) await context.channel.sendMessage(target, `Pairing code not found: ${code}`);
2907
- else {
2908
- context.db.appendAuditEvent({
2909
- actor: "channel",
2910
- eventType: "telegram.pair.reject",
2911
- payload: {
2912
- accountId,
2913
- code,
2914
- userId: result.userId,
2915
- approver: context.msg.userId
2916
- }
2917
- });
2918
- await context.channel.sendMessage(target, `Rejected pairing for user ${result.userId}.`);
3911
+ if (subcommand === "set") {
3912
+ if (!body) {
3913
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} set <content>`);
3914
+ return { handled: true };
2919
3915
  }
2920
- return true;
3916
+ await writeWorkspaceFile(context.agentId, fileName, body);
3917
+ await context.channel.sendMessage(target, `${fileName} updated.`);
3918
+ return { handled: true };
2921
3919
  }
2922
- await context.channel.sendMessage(target, "Unknown /pair action. Use approve or reject.");
2923
- return true;
3920
+ if (subcommand === "append") {
3921
+ if (!body) {
3922
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} append <content>`);
3923
+ return { handled: true };
3924
+ }
3925
+ const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
3926
+ const next = current.trim().length > 0 ? `${current.trimEnd()}\n${body}` : body;
3927
+ await writeWorkspaceFile(context.agentId, fileName, next);
3928
+ await context.channel.sendMessage(target, `${fileName} appended.`);
3929
+ return { handled: true };
3930
+ }
3931
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} [view]\n/${parsed.command} set <content>\n/${parsed.command} append <content>`);
3932
+ return { handled: true };
3933
+ }
3934
+ if (parsed.command === "reset" || parsed.command === "new") {
3935
+ const remainder = parsed.args.join(" ").trim();
3936
+ if (!remainder) return {
3937
+ handled: true,
3938
+ resetSession: true
3939
+ };
3940
+ return {
3941
+ handled: false,
3942
+ resetSession: true,
3943
+ promptOverride: remainder
3944
+ };
2924
3945
  }
2925
- return false;
3946
+ if (parsed.command === "skill") {
3947
+ const sub = parsed.args[0]?.toLowerCase();
3948
+ if (!sub) {
3949
+ await context.channel.sendMessage(target, "Usage: /skill <name> [input] | /skill create <name> [description] | /skill add <name>");
3950
+ return { handled: true };
3951
+ }
3952
+ if (sub === "create") {
3953
+ const name = normalizeSkillName(parsed.args[1]);
3954
+ if (!name) {
3955
+ await context.channel.sendMessage(target, "Usage: /skill create <name> [description]");
3956
+ return { handled: true };
3957
+ }
3958
+ const description = parsed.args.slice(2).join(" ").trim() || `Skill ${name}`;
3959
+ const skillDir = path.join(config.skillsDir, name);
3960
+ const skillPath = path.join(skillDir, "SKILL.md");
3961
+ const scaffold = [
3962
+ "---",
3963
+ `name: ${name}`,
3964
+ `description: ${description}`,
3965
+ "---",
3966
+ "",
3967
+ `# ${name}`,
3968
+ "",
3969
+ "Describe how this skill should solve the task."
3970
+ ].join("\n");
3971
+ try {
3972
+ await fs$1.mkdir(skillDir, { recursive: false });
3973
+ } catch {
3974
+ await context.channel.sendMessage(target, `Skill already exists: ${name}`);
3975
+ return { handled: true };
3976
+ }
3977
+ await fs$1.writeFile(skillPath, `${scaffold}\n`, "utf8");
3978
+ await context.channel.sendMessage(target, `Created ${skillPath}`);
3979
+ return { handled: true };
3980
+ }
3981
+ if (sub === "add") {
3982
+ const skillName = normalizeSkillName(parsed.args[1]);
3983
+ if (!skillName) {
3984
+ await context.channel.sendMessage(target, "Usage: /skill add <name>");
3985
+ return { handled: true };
3986
+ }
3987
+ const { added, path: agentPath } = await addSkillToAgent(context.agentId, skillName);
3988
+ await context.channel.sendMessage(target, added ? `Added ${skillName} to ${agentPath}` : `${skillName} is already enabled for ${context.agentId}.`);
3989
+ return { handled: true };
3990
+ }
3991
+ const targetSkill = normalizeSkillName(sub);
3992
+ if (!targetSkill) {
3993
+ await context.channel.sendMessage(target, "Invalid skill name.");
3994
+ return { handled: true };
3995
+ }
3996
+ if (!new Set(listAvailableSkills()).has(targetSkill)) {
3997
+ await context.channel.sendMessage(target, `Skill not found: ${targetSkill}. Run /skills to list installed skills or /skill create ${targetSkill}.`);
3998
+ return { handled: true };
3999
+ }
4000
+ return {
4001
+ handled: false,
4002
+ promptOverride: resolveSkillPrompt(targetSkill, parsed.args.slice(1))
4003
+ };
4004
+ }
4005
+ const skillCommand = listSkillCommandSpecs(config).find((entry) => entry.name === parsed.command);
4006
+ if (skillCommand) return {
4007
+ handled: false,
4008
+ promptOverride: resolveSkillPrompt(skillCommand.skillName, parsed.args)
4009
+ };
4010
+ if (parsed.command === "think") {
4011
+ if (parsed.args.length === 0) {
4012
+ await context.channel.sendMessage(target, [
4013
+ `Default thinking level: ${config.commands.defaultThinkingLevel}`,
4014
+ "Usage:",
4015
+ "/think <low|medium|high> <task>",
4016
+ "/think default <low|medium|high>"
4017
+ ].join("\n"));
4018
+ return { handled: true };
4019
+ }
4020
+ if (parsed.args[0]?.trim().toLowerCase() === "default") {
4021
+ const requested = parseThinkingLevel(parsed.args[1]);
4022
+ if (!requested) {
4023
+ await context.channel.sendMessage(target, `Current default thinking level: ${config.commands.defaultThinkingLevel}\nUsage: /think default <low|medium|high>`);
4024
+ return { handled: true };
4025
+ }
4026
+ const next = loadFileConfig();
4027
+ next.commands.defaultThinkingLevel = requested;
4028
+ try {
4029
+ persistFileConfig(next);
4030
+ } catch (error) {
4031
+ const message = error instanceof Error ? error.message : String(error);
4032
+ await context.channel.sendMessage(target, `Failed to update default thinking level: ${message}`);
4033
+ return { handled: true };
4034
+ }
4035
+ await context.channel.sendMessage(target, `Default thinking level set to ${requested}.`);
4036
+ return { handled: true };
4037
+ }
4038
+ const level = parseThinkingLevel(parsed.args[0]);
4039
+ if (!level || parsed.args.length < 2) {
4040
+ await context.channel.sendMessage(target, "Usage: /think <low|medium|high> <task>\nOr set default: /think default <low|medium|high>");
4041
+ return { handled: true };
4042
+ }
4043
+ return {
4044
+ handled: false,
4045
+ promptOverride: parsed.args.slice(1).join(" ").trim(),
4046
+ thinkingLevelOverride: level
4047
+ };
4048
+ }
4049
+ if (parsed.command === "reasoning") {
4050
+ if (parsed.args.length < 2) {
4051
+ await context.channel.sendMessage(target, "Usage: /reasoning <on|off> <task>\nThis command is per-message and not persisted.");
4052
+ return { handled: true };
4053
+ }
4054
+ const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
4055
+ const task = parsed.args.slice(1).join(" ").trim();
4056
+ return {
4057
+ handled: false,
4058
+ promptOverride: mode === "on" ? `Solve this task and include concise reasoning in your response: ${task}` : `Solve this task and return only final output without exposing internal reasoning: ${task}`
4059
+ };
4060
+ }
4061
+ if (parsed.command === "verbose") {
4062
+ if (parsed.args.length < 2) {
4063
+ await context.channel.sendMessage(target, "Usage: /verbose <on|off> <task>\nThis command is per-message and not persisted.");
4064
+ return { handled: true };
4065
+ }
4066
+ const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
4067
+ const task = parsed.args.slice(1).join(" ").trim();
4068
+ return {
4069
+ handled: false,
4070
+ promptOverride: mode === "on" ? `Answer in a detailed, explicit style for this request: ${task}` : `Answer briefly and directly for this request: ${task}`
4071
+ };
4072
+ }
4073
+ if (parsed.command === "stop") {
4074
+ await context.channel.sendMessage(target, "Stopping an in-flight run is not supported yet. Use /new to start a fresh session.");
4075
+ return { handled: true };
4076
+ }
4077
+ if (parsed.command === "restart") {
4078
+ if (!config.commands.restart) {
4079
+ await context.channel.sendMessage(target, "Restart command is disabled. Set commands.restart=true to enable.");
4080
+ return { handled: true };
4081
+ }
4082
+ await context.channel.sendMessage(target, "Restart requested. Applying restart now.");
4083
+ try {
4084
+ if ((await requestDaemonRestartFromCurrentProcess()).mode === "spawn-reexec") setTimeout(() => {
4085
+ try {
4086
+ process.kill(process.pid, "SIGTERM");
4087
+ } catch {}
4088
+ }, 250);
4089
+ } catch (error) {
4090
+ const message = error instanceof Error ? error.message : String(error);
4091
+ await context.channel.sendMessage(target, `Restart failed: ${message}`);
4092
+ }
4093
+ return { handled: true };
4094
+ }
4095
+ if (parsed.command === "config") {
4096
+ if (!config.commands.config) {
4097
+ await context.channel.sendMessage(target, "/config is disabled. Set commands.config=true to enable.");
4098
+ return { handled: true };
4099
+ }
4100
+ const sub = parsed.args[0]?.toLowerCase() ?? "show";
4101
+ if (sub === "show" || sub === "status") {
4102
+ const current = loadFileConfig();
4103
+ await context.channel.sendMessage(target, trimForTelegram(configSummaryText(current)));
4104
+ return { handled: true };
4105
+ }
4106
+ if (sub === "reload") {
4107
+ try {
4108
+ reloadRuntimeConfig();
4109
+ } catch (error) {
4110
+ const message = error instanceof Error ? error.message : String(error);
4111
+ await context.channel.sendMessage(target, `Failed to reload config: ${message}`);
4112
+ return { handled: true };
4113
+ }
4114
+ await context.channel.sendMessage(target, "Reloaded runtime config from disk.");
4115
+ return { handled: true };
4116
+ }
4117
+ if (sub === "get") {
4118
+ const pathSegments = parseConfigPath(parsed.args[1]);
4119
+ if (!pathSegments) {
4120
+ await context.channel.sendMessage(target, "Usage: /config get <path>");
4121
+ return { handled: true };
4122
+ }
4123
+ const found = readPathValue(loadFileConfig(), pathSegments);
4124
+ if (!found.found) {
4125
+ await context.channel.sendMessage(target, `Path not found: ${parsed.args[1]}`);
4126
+ return { handled: true };
4127
+ }
4128
+ const pretty = formatValue(found.value);
4129
+ await context.channel.sendMessage(target, trimForTelegram(`${parsed.args[1]} = ${pretty}`));
4130
+ return { handled: true };
4131
+ }
4132
+ if (sub === "set") {
4133
+ const pathSegments = parseConfigPath(parsed.args[1]);
4134
+ const valueRaw = parsed.args.slice(2).join(" ");
4135
+ if (!pathSegments || !valueRaw.trim()) {
4136
+ await context.channel.sendMessage(target, "Usage: /config set <path> <json-or-text>");
4137
+ return { handled: true };
4138
+ }
4139
+ const next = loadFileConfig();
4140
+ if (!writePathValue(next, pathSegments, parseConfigValue(valueRaw))) {
4141
+ await context.channel.sendMessage(target, `Could not update path: ${parsed.args[1]}`);
4142
+ return { handled: true };
4143
+ }
4144
+ try {
4145
+ persistFileConfig(next);
4146
+ } catch (error) {
4147
+ const message = error instanceof Error ? error.message : String(error);
4148
+ await context.channel.sendMessage(target, `Config update failed: ${message}`);
4149
+ return { handled: true };
4150
+ }
4151
+ await context.channel.sendMessage(target, `Updated ${parsed.args[1]}.`);
4152
+ return { handled: true };
4153
+ }
4154
+ if (sub === "patch") {
4155
+ const patchRaw = parsed.args.slice(1).join(" ").trim();
4156
+ if (!patchRaw) {
4157
+ await context.channel.sendMessage(target, "Usage: /config patch <json-object>");
4158
+ return { handled: true };
4159
+ }
4160
+ let patch;
4161
+ try {
4162
+ patch = JSON.parse(patchRaw);
4163
+ } catch (error) {
4164
+ const message = error instanceof Error ? error.message : String(error);
4165
+ await context.channel.sendMessage(target, `Invalid JSON patch: ${message}`);
4166
+ return { handled: true };
4167
+ }
4168
+ if (!isRecord$1(patch)) {
4169
+ await context.channel.sendMessage(target, "Patch must be a JSON object.");
4170
+ return { handled: true };
4171
+ }
4172
+ const merged = deepMerge$1(loadFileConfig(), patch);
4173
+ try {
4174
+ persistFileConfig(merged);
4175
+ } catch (error) {
4176
+ const message = error instanceof Error ? error.message : String(error);
4177
+ await context.channel.sendMessage(target, `Config patch failed: ${message}`);
4178
+ return { handled: true };
4179
+ }
4180
+ await context.channel.sendMessage(target, "Config patch applied.");
4181
+ return { handled: true };
4182
+ }
4183
+ await context.channel.sendMessage(target, "Usage: /config show | get <path> | set <path> <value> | patch <json-object> | reload");
4184
+ return { handled: true };
4185
+ }
4186
+ if (parsed.command === "debug") {
4187
+ const sub = parsed.args[0]?.toLowerCase() ?? "status";
4188
+ const canSelfEnable = sub === "on";
4189
+ if (!config.commands.debug && !canSelfEnable) {
4190
+ await context.channel.sendMessage(target, "/debug is disabled. Set commands.debug=true to enable.");
4191
+ return { handled: true };
4192
+ }
4193
+ if (sub === "on" || sub === "off") {
4194
+ const next = loadFileConfig();
4195
+ next.commands.debug = sub === "on";
4196
+ try {
4197
+ persistFileConfig(next);
4198
+ } catch (error) {
4199
+ const message = error instanceof Error ? error.message : String(error);
4200
+ await context.channel.sendMessage(target, `Failed to update debug mode: ${message}`);
4201
+ return { handled: true };
4202
+ }
4203
+ await context.channel.sendMessage(target, `commands.debug=${next.commands.debug}`);
4204
+ return { handled: true };
4205
+ }
4206
+ if (sub === "level") {
4207
+ const requested = parsed.args[1]?.trim().toLowerCase();
4208
+ if (!requested || !DEBUG_LOG_LEVELS.has(requested)) {
4209
+ await context.channel.sendMessage(target, "Usage: /debug level <trace|debug|info|warn|error|fatal|silent>");
4210
+ return { handled: true };
4211
+ }
4212
+ logger.level = requested;
4213
+ process.env.LOG_LEVEL = requested;
4214
+ await context.channel.sendMessage(target, `Logger level set to ${requested} (runtime only).`);
4215
+ return { handled: true };
4216
+ }
4217
+ if (sub === "dump") {
4218
+ const payload = {
4219
+ pid: process.pid,
4220
+ uptimeSec: Math.floor(process.uptime()),
4221
+ loggerLevel: logger.level,
4222
+ commands: config.commands,
4223
+ activeSessions: context.db.listSessions().length,
4224
+ channelDiagnostics: context.channel.getDiagnostics?.() ?? {}
4225
+ };
4226
+ await context.channel.sendMessage(target, trimForTelegram(JSON.stringify(payload, null, 2)));
4227
+ return { handled: true };
4228
+ }
4229
+ if (sub === "status") {
4230
+ const lines = [
4231
+ "Debug status",
4232
+ `pid=${process.pid}`,
4233
+ `uptimeSec=${Math.floor(process.uptime())}`,
4234
+ `logger.level=${logger.level}`,
4235
+ `commands.debug=${config.commands.debug}`,
4236
+ `commands.config=${config.commands.config}`,
4237
+ `commands.bash=${config.commands.bash}`,
4238
+ `commands.restart=${config.commands.restart}`,
4239
+ `activeSessions=${context.db.listSessions().length}`,
4240
+ "",
4241
+ "Usage:",
4242
+ "/debug status",
4243
+ "/debug level <trace|debug|info|warn|error|fatal|silent>",
4244
+ "/debug dump",
4245
+ "/debug on|off"
4246
+ ];
4247
+ await context.channel.sendMessage(target, lines.join("\n"));
4248
+ return { handled: true };
4249
+ }
4250
+ await context.channel.sendMessage(target, "Usage: /debug status | level <...> | dump | on | off");
4251
+ return { handled: true };
4252
+ }
4253
+ if (parsed.command === "bash") {
4254
+ if (!config.commands.bash) {
4255
+ await context.channel.sendMessage(target, "/bash is disabled. Set commands.bash=true to enable.");
4256
+ return { handled: true };
4257
+ }
4258
+ return {
4259
+ handled: false,
4260
+ promptOverride: `Run this shell command safely: ${parsed.args.join(" ").trim()}`
4261
+ };
4262
+ }
4263
+ return { handled: false };
2926
4264
  }
2927
4265
 
2928
4266
  //#endregion
2929
4267
  //#region src/channels/telegram-pairing-store.ts
4268
+ function emptyState() {
4269
+ return {
4270
+ approved: {},
4271
+ pending: {}
4272
+ };
4273
+ }
2930
4274
  function nowIso$1() {
2931
4275
  return (/* @__PURE__ */ new Date()).toISOString();
2932
4276
  }
@@ -2935,44 +4279,31 @@ function randomCode() {
2935
4279
  }
2936
4280
  var TelegramPairingStore = class {
2937
4281
  filePath;
2938
- state = null;
2939
4282
  constructor(storeDir) {
2940
4283
  this.filePath = path.join(storeDir, "telegram-pairing.json");
2941
4284
  }
2942
- load() {
2943
- if (this.state) return this.state;
2944
- if (!fs.existsSync(this.filePath)) {
2945
- this.state = {
2946
- approved: {},
2947
- pending: {}
2948
- };
2949
- return this.state;
2950
- }
4285
+ readState() {
4286
+ if (!fs.existsSync(this.filePath)) return emptyState();
2951
4287
  try {
2952
4288
  const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
2953
- this.state = {
4289
+ if (!parsed || typeof parsed !== "object") return emptyState();
4290
+ return {
2954
4291
  approved: parsed.approved ?? {},
2955
4292
  pending: parsed.pending ?? {}
2956
4293
  };
2957
- return this.state;
2958
4294
  } catch {
2959
- this.state = {
2960
- approved: {},
2961
- pending: {}
2962
- };
2963
- return this.state;
4295
+ return emptyState();
2964
4296
  }
2965
4297
  }
2966
- save() {
2967
- if (!this.state) return;
4298
+ writeState(state) {
2968
4299
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
2969
- fs.writeFileSync(this.filePath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
4300
+ fs.writeFileSync(this.filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
2970
4301
  }
2971
4302
  isApproved(accountId, userId) {
2972
- return (this.load().approved[accountId] ?? []).includes(userId);
4303
+ return (this.readState().approved[accountId] ?? []).includes(userId);
2973
4304
  }
2974
4305
  ensurePendingCode(accountId, userId) {
2975
- const state = this.load();
4306
+ const state = this.readState();
2976
4307
  const pendingByAccount = state.pending[accountId] ?? {};
2977
4308
  const existing = Object.entries(pendingByAccount).find(([, entry]) => entry.userId === userId);
2978
4309
  if (existing) return existing[0];
@@ -2984,11 +4315,11 @@ var TelegramPairingStore = class {
2984
4315
  createdAt: nowIso$1()
2985
4316
  }
2986
4317
  };
2987
- this.save();
4318
+ this.writeState(state);
2988
4319
  return code;
2989
4320
  }
2990
4321
  approveByCode(accountId, code) {
2991
- const state = this.load();
4322
+ const state = this.readState();
2992
4323
  const pendingByAccount = state.pending[accountId] ?? {};
2993
4324
  const entry = pendingByAccount[code];
2994
4325
  if (!entry) return { ok: false };
@@ -2997,118 +4328,150 @@ var TelegramPairingStore = class {
2997
4328
  state.approved[accountId] = Array.from(approved).sort((a, b) => a.localeCompare(b));
2998
4329
  delete pendingByAccount[code];
2999
4330
  state.pending[accountId] = pendingByAccount;
3000
- this.save();
4331
+ this.writeState(state);
4332
+ return {
4333
+ ok: true,
4334
+ userId: entry.userId
4335
+ };
4336
+ }
4337
+ rejectByCode(accountId, code) {
4338
+ const state = this.readState();
4339
+ const pendingByAccount = state.pending[accountId] ?? {};
4340
+ const entry = pendingByAccount[code];
4341
+ if (!entry) return { ok: false };
4342
+ delete pendingByAccount[code];
4343
+ state.pending[accountId] = pendingByAccount;
4344
+ this.writeState(state);
3001
4345
  return {
3002
4346
  ok: true,
3003
4347
  userId: entry.userId
3004
4348
  };
3005
- }
3006
- rejectByCode(accountId, code) {
3007
- const state = this.load();
3008
- const pendingByAccount = state.pending[accountId] ?? {};
3009
- const entry = pendingByAccount[code];
3010
- if (!entry) return { ok: false };
3011
- delete pendingByAccount[code];
3012
- state.pending[accountId] = pendingByAccount;
3013
- this.save();
4349
+ }
4350
+ };
4351
+
4352
+ //#endregion
4353
+ //#region src/channels/telegram-policy.ts
4354
+ function toIdSet(values) {
4355
+ return new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean));
4356
+ }
4357
+ function splitThread(chatId) {
4358
+ const [baseChatIdRaw, threadIdRaw] = chatId.split("#");
4359
+ return {
4360
+ baseChatId: baseChatIdRaw ?? chatId,
4361
+ ...threadIdRaw ? { threadId: threadIdRaw } : {}
4362
+ };
4363
+ }
4364
+ function isDirectMessage(msg) {
4365
+ return msg.peer?.kind === "direct" || !msg.chatId.startsWith("-") && !msg.chatId.includes("#");
4366
+ }
4367
+ function containsMention(text, assistantName) {
4368
+ const normalized = assistantName.trim().toLowerCase();
4369
+ if (!normalized) return false;
4370
+ return text.toLowerCase().includes(`@${normalized}`);
4371
+ }
4372
+ function isAllowListed(msg, allowSet) {
4373
+ if (allowSet.has("*")) return true;
4374
+ const userId = msg.userId.trim().toLowerCase();
4375
+ if (userId && allowSet.has(userId)) return true;
4376
+ const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
4377
+ if (displayName && allowSet.has(displayName)) return true;
4378
+ return false;
4379
+ }
4380
+ function evaluateTelegramPolicy(params) {
4381
+ const { config, msg, pairingStore } = params;
4382
+ const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
4383
+ const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
4384
+ if (!account || account.enabled === false) return {
4385
+ allowed: false,
4386
+ reason: "account-disabled"
4387
+ };
4388
+ const direct = isDirectMessage(msg);
4389
+ const allowFromSet = toIdSet(account.allowFrom);
4390
+ if (direct) {
4391
+ const dmPolicy = account.dmPolicy ?? "pairing";
4392
+ if (dmPolicy === "disabled") return {
4393
+ allowed: false,
4394
+ reason: "dm-disabled"
4395
+ };
4396
+ if (dmPolicy === "open") return { allowed: true };
4397
+ if (dmPolicy === "allowlist") {
4398
+ if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
4399
+ return {
4400
+ allowed: false,
4401
+ reason: "dm-not-allowlisted"
4402
+ };
4403
+ }
4404
+ if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
3014
4405
  return {
3015
- ok: true,
3016
- userId: entry.userId
4406
+ allowed: false,
4407
+ reason: "pairing-required",
4408
+ pairingCode: pairingStore.ensurePendingCode(accountId, msg.userId)
3017
4409
  };
3018
4410
  }
3019
- };
3020
-
3021
- //#endregion
3022
- //#region src/compat/openclaw-mirror.ts
3023
- function resolveOpenClawHome(env = process.env) {
3024
- const override = env.OPENCLAW_STATE_DIR?.trim();
3025
- if (override) return path.resolve(override.startsWith("~") ? path.join(os.homedir(), override.slice(1)) : override);
3026
- return path.join(os.homedir(), ".openclaw");
3027
- }
3028
- function resolveOpenClawConfigPath(openclawHome) {
3029
- return path.join(openclawHome, "openclaw.json");
3030
- }
3031
- function resolveOpenClawSharedSkillsPath(openclawHome) {
3032
- return path.join(openclawHome, "skills");
3033
- }
3034
- function buildMirrorConfig(config) {
3035
- const fallbackWorkspace = config.agents.defaults.workspace || path.join(config.hovclawHome, "workspace");
3036
- const agentList = config.agents.list.length > 0 ? config.agents.list : [{
3037
- id: "main",
3038
- name: "Main",
3039
- workspace: fallbackWorkspace,
3040
- default: true
3041
- }];
3042
- const extraDirs = /* @__PURE__ */ new Set();
3043
- extraDirs.add(config.skillsDir);
3044
- for (const agent of agentList) {
3045
- const workspace = (agent.workspace || fallbackWorkspace).trim();
3046
- if (!workspace) continue;
3047
- extraDirs.add(path.join(workspace, "skills"));
4411
+ const { baseChatId, threadId } = splitThread(msg.chatId);
4412
+ const groupConfig = account.groups?.[baseChatId];
4413
+ const topicConfig = threadId ? groupConfig?.topics?.[threadId] : void 0;
4414
+ if (groupConfig?.enabled === false) return {
4415
+ allowed: false,
4416
+ reason: "group-disabled"
4417
+ };
4418
+ if (topicConfig?.enabled === false) return {
4419
+ allowed: false,
4420
+ reason: "topic-disabled"
4421
+ };
4422
+ const groupPolicy = topicConfig?.groupPolicy ?? groupConfig?.groupPolicy ?? account.groupPolicy ?? "open";
4423
+ if (groupPolicy === "disabled") return {
4424
+ allowed: false,
4425
+ reason: "group-disabled"
4426
+ };
4427
+ if (groupPolicy === "allowlist") {
4428
+ if (!isAllowListed(msg, toIdSet(topicConfig?.allowFrom ?? groupConfig?.allowFrom ?? account.groupAllowFrom))) return {
4429
+ allowed: false,
4430
+ reason: "group-not-allowlisted"
4431
+ };
3048
4432
  }
3049
- return {
3050
- agent: { workspace: fallbackWorkspace },
3051
- agents: {
3052
- defaults: { workspace: fallbackWorkspace },
3053
- list: agentList
3054
- },
3055
- skills: { load: { extraDirs: Array.from(extraDirs) } }
4433
+ if ((topicConfig?.requireMention ?? groupConfig?.requireMention ?? false) && !containsMention(msg.text, config.assistantName)) return {
4434
+ allowed: false,
4435
+ reason: "mention-required"
3056
4436
  };
4437
+ return { allowed: true };
3057
4438
  }
3058
- function ensureDir(dirPath) {
3059
- fs.mkdirSync(dirPath, {
3060
- recursive: true,
3061
- mode: 448
3062
- });
3063
- }
3064
- function syncSkillsDir(sharedSkillsPath, sourceSkillsPath) {
3065
- if (!fs.existsSync(sourceSkillsPath)) {
3066
- ensureDir(sharedSkillsPath);
3067
- return false;
3068
- }
3069
- try {
3070
- if (fs.lstatSync(sharedSkillsPath).isSymbolicLink()) {
3071
- const linkTarget = fs.readlinkSync(sharedSkillsPath);
3072
- if (path.resolve(path.dirname(sharedSkillsPath), linkTarget) === sourceSkillsPath) return true;
3073
- fs.unlinkSync(sharedSkillsPath);
3074
- }
3075
- } catch {}
3076
- if (!fs.existsSync(sharedSkillsPath)) try {
3077
- fs.symlinkSync(sourceSkillsPath, sharedSkillsPath, "dir");
3078
- return true;
3079
- } catch {}
3080
- ensureDir(sharedSkillsPath);
3081
- for (const entry of fs.readdirSync(sourceSkillsPath, { withFileTypes: true })) {
3082
- const src = path.join(sourceSkillsPath, entry.name);
3083
- const dst = path.join(sharedSkillsPath, entry.name);
3084
- if (entry.isDirectory()) {
3085
- fs.cpSync(src, dst, {
3086
- recursive: true,
3087
- force: true
3088
- });
4439
+
4440
+ //#endregion
4441
+ //#region src/redaction.ts
4442
+ const REDACTED_VALUE = "[REDACTED]";
4443
+ const SENSITIVE_KEY_PATTERNS = [
4444
+ /token/i,
4445
+ /password/i,
4446
+ /secret/i,
4447
+ /api[_-]?key/i,
4448
+ /^key$/i,
4449
+ /authorization/i,
4450
+ /cookie/i,
4451
+ /refresh/i,
4452
+ /access/i
4453
+ ];
4454
+ function isSensitiveKey(key) {
4455
+ return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
4456
+ }
4457
+ function redactValue(value, seen) {
4458
+ if (Array.isArray(value)) return value.map((entry) => redactValue(entry, seen));
4459
+ if (!value || typeof value !== "object") return value;
4460
+ if (seen.has(value)) return "[Circular]";
4461
+ seen.add(value);
4462
+ const record = value;
4463
+ const result = {};
4464
+ for (const [key, entry] of Object.entries(record)) {
4465
+ if (isSensitiveKey(key)) {
4466
+ result[key] = REDACTED_VALUE;
3089
4467
  continue;
3090
4468
  }
3091
- if (entry.isFile()) fs.copyFileSync(src, dst);
4469
+ result[key] = redactValue(entry, seen);
3092
4470
  }
3093
- return false;
4471
+ return result;
3094
4472
  }
3095
- function writeOpenClawMirror(config) {
3096
- const openclawHome = resolveOpenClawHome();
3097
- const configPath = resolveOpenClawConfigPath(openclawHome);
3098
- const sharedSkillsPath = resolveOpenClawSharedSkillsPath(openclawHome);
3099
- ensureDir(openclawHome);
3100
- const mirrorConfig = buildMirrorConfig(config);
3101
- fs.writeFileSync(configPath, `${JSON.stringify(mirrorConfig, null, 2)}\n`, {
3102
- encoding: "utf8",
3103
- mode: 384
3104
- });
3105
- fs.chmodSync(configPath, 384);
3106
- return {
3107
- openclawHome,
3108
- configPath,
3109
- sharedSkillsPath,
3110
- linkedSharedSkills: syncSkillsDir(sharedSkillsPath, config.skillsDir)
3111
- };
4473
+ function redactSensitiveData(value) {
4474
+ return redactValue(value, /* @__PURE__ */ new WeakSet());
3112
4475
  }
3113
4476
 
3114
4477
  //#endregion
@@ -3384,6 +4747,11 @@ var HovClawDb = class {
3384
4747
  getAgentState(sessionKey) {
3385
4748
  return this.db.prepare(`SELECT state_json FROM agent_state WHERE session_key = ?`).get(sessionKey)?.state_json ?? null;
3386
4749
  }
4750
+ clearSession(sessionKey) {
4751
+ this.db.prepare(`DELETE FROM agent_state WHERE session_key = ?`).run(sessionKey);
4752
+ this.db.prepare(`DELETE FROM messages WHERE session_key = ?`).run(sessionKey);
4753
+ this.db.prepare(`DELETE FROM sessions WHERE session_key = ?`).run(sessionKey);
4754
+ }
3387
4755
  recordUsageCost(record) {
3388
4756
  this.db.prepare(`
3389
4757
  INSERT INTO usage_costs (
@@ -3525,10 +4893,11 @@ var HovClawDb = class {
3525
4893
  }));
3526
4894
  }
3527
4895
  appendAuditEvent(record) {
4896
+ const sanitizedPayload = redactSensitiveData(record.payload);
3528
4897
  this.db.prepare(`
3529
4898
  INSERT INTO audit_log (ts, session_key, actor, event_type, payload_json)
3530
4899
  VALUES (?, ?, ?, ?, ?)
3531
- `).run(record.ts ?? nowIso(), record.sessionKey ?? null, record.actor, record.eventType, JSON.stringify(record.payload));
4900
+ `).run(record.ts ?? nowIso(), record.sessionKey ?? null, record.actor, record.eventType, JSON.stringify(sanitizedPayload));
3532
4901
  }
3533
4902
  getAuditEvents(eventType) {
3534
4903
  const query = eventType ? `SELECT ts, session_key, actor, event_type, payload_json FROM audit_log WHERE event_type = ? ORDER BY id DESC` : `SELECT ts, session_key, actor, event_type, payload_json FROM audit_log ORDER BY id DESC`;
@@ -3709,7 +5078,7 @@ function deepMerge(base, patch) {
3709
5078
  return result;
3710
5079
  }
3711
5080
  const configGetMethod = async (_params, context) => {
3712
- return context.readFileConfig();
5081
+ return redactSensitiveData(context.readFileConfig());
3713
5082
  };
3714
5083
  const configSetMethod = async (params, context) => {
3715
5084
  const parsed = configSetParamsSchema.parse(params);
@@ -3791,7 +5160,7 @@ const logsTailMethod = async (params, context) => {
3791
5160
  message: event.eventType,
3792
5161
  sessionKey: event.sessionKey,
3793
5162
  actor: event.actor,
3794
- payload: event.payload
5163
+ payload: redactSensitiveData(event.payload)
3795
5164
  })) };
3796
5165
  };
3797
5166
 
@@ -4120,7 +5489,46 @@ function sendResponse(socket, id, ok, payload, error) {
4120
5489
  function setUiSecurityHeaders(res) {
4121
5490
  res.setHeader("X-Frame-Options", "DENY");
4122
5491
  res.setHeader("X-Content-Type-Options", "nosniff");
4123
- res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
5492
+ res.setHeader("Content-Security-Policy", [
5493
+ "default-src 'self'",
5494
+ "script-src 'self'",
5495
+ "style-src 'self'",
5496
+ "img-src 'self' data:",
5497
+ "font-src 'self'",
5498
+ "connect-src 'self'",
5499
+ "object-src 'none'",
5500
+ "base-uri 'none'",
5501
+ "frame-ancestors 'none'",
5502
+ "form-action 'none'"
5503
+ ].join("; "));
5504
+ }
5505
+ function normalizeOrigin(value) {
5506
+ try {
5507
+ const parsed = new URL(value);
5508
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
5509
+ return `${parsed.protocol}//${parsed.host}`.toLowerCase();
5510
+ } catch {
5511
+ return null;
5512
+ }
5513
+ }
5514
+ function isOriginAllowed(request, config) {
5515
+ const rawOrigin = request.headers.origin;
5516
+ if (typeof rawOrigin !== "string" || rawOrigin.trim().length === 0) return true;
5517
+ const normalizedOrigin = normalizeOrigin(rawOrigin.trim());
5518
+ if (!normalizedOrigin) return false;
5519
+ const rawHost = request.headers.host;
5520
+ if (typeof rawHost === "string" && rawHost.trim().length > 0) {
5521
+ const normalizedHost = rawHost.trim().toLowerCase();
5522
+ if (normalizedOrigin === `http://${normalizedHost}` || normalizedOrigin === `https://${normalizedHost}`) return true;
5523
+ }
5524
+ const allowedOrigins = config.gateway.auth.allowedOrigins;
5525
+ if (allowedOrigins.includes("*")) return true;
5526
+ for (const allowed of allowedOrigins) {
5527
+ const normalizedAllowed = normalizeOrigin(allowed);
5528
+ if (!normalizedAllowed) continue;
5529
+ if (normalizedAllowed === normalizedOrigin) return true;
5530
+ }
5531
+ return false;
4124
5532
  }
4125
5533
  function contentTypeForFile(filePath) {
4126
5534
  const ext = path.extname(filePath).toLowerCase();
@@ -4221,6 +5629,15 @@ var HovClawGatewayServer = class {
4221
5629
  socket.destroy();
4222
5630
  return;
4223
5631
  }
5632
+ if (!isOriginAllowed(request, this.runtimeConfig)) {
5633
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
5634
+ socket.destroy();
5635
+ logger.warn({
5636
+ origin: request.headers.origin,
5637
+ host: request.headers.host
5638
+ }, "Rejected websocket upgrade due to origin policy");
5639
+ return;
5640
+ }
4224
5641
  this.wss.handleUpgrade(request, socket, head, (wsSocket) => {
4225
5642
  this.wss?.emit("connection", wsSocket, request);
4226
5643
  });
@@ -4335,7 +5752,14 @@ var HovClawGatewayServer = class {
4335
5752
  const connectParams = parseConnectParams(request.params);
4336
5753
  const expectedToken = this.runtimeConfig.gateway.auth.token.trim();
4337
5754
  const expectedPassword = this.runtimeConfig.gateway.auth.password.trim();
4338
- if (!expectedToken && !expectedPassword) return { ok: true };
5755
+ const allowUnauthenticated = this.runtimeConfig.gateway.auth.allowUnauthenticated;
5756
+ if (!expectedToken && !expectedPassword) {
5757
+ if (allowUnauthenticated) return { ok: true };
5758
+ return {
5759
+ ok: false,
5760
+ reason: "gateway auth required"
5761
+ };
5762
+ }
4339
5763
  if (expectedToken && connectParams.auth?.token === expectedToken) return { ok: true };
4340
5764
  if (expectedPassword && connectParams.auth?.password === expectedPassword) return { ok: true };
4341
5765
  return {
@@ -4612,19 +6036,67 @@ function extractCommandPrefix$1(command) {
4612
6036
  if (!trimmed) return "";
4613
6037
  return trimmed.split(/\s+/)[0] ?? "";
4614
6038
  }
4615
- function splitCommandSegments$1(command) {
4616
- return command.split(/(?:&&|\|\||[;|\n])/).map((segment) => segment.trim()).filter(Boolean);
6039
+ function nonOptionArgs$1(args) {
6040
+ const out = [];
6041
+ let skipNext = false;
6042
+ for (const arg of args) {
6043
+ if (skipNext) {
6044
+ skipNext = false;
6045
+ continue;
6046
+ }
6047
+ if (arg === "--") break;
6048
+ if (arg === "-n" || arg === "-c" || arg === "--max-depth" || arg === "--glob" || arg === "--iglob" || arg === "--type" || arg === "--type-not" || arg === "-e" || arg === "-f") {
6049
+ skipNext = true;
6050
+ continue;
6051
+ }
6052
+ if (arg.startsWith("-")) continue;
6053
+ out.push(arg);
6054
+ }
6055
+ return out;
6056
+ }
6057
+ function isLikelyPathOperand$1(value) {
6058
+ const trimmed = value.trim();
6059
+ if (!trimmed) return false;
6060
+ if (/^\d+$/.test(trimmed)) return false;
6061
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return false;
6062
+ if (trimmed === "." || trimmed === ".." || trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~/") || trimmed.includes("/")) return true;
6063
+ return /^[A-Za-z0-9._-]+$/.test(trimmed);
4617
6064
  }
4618
- function validateCommand$1(command, allowedCommandPrefixes) {
6065
+ function collectReadPathOperands$1(prefix, args) {
6066
+ if (prefix === "rg") {
6067
+ const plain = nonOptionArgs$1(args);
6068
+ if (plain.length <= 1) return [];
6069
+ return plain.slice(1);
6070
+ }
6071
+ if (prefix === "find") {
6072
+ const plain = nonOptionArgs$1(args);
6073
+ return plain.length > 0 ? [plain[0]] : [];
6074
+ }
6075
+ return nonOptionArgs$1(args);
6076
+ }
6077
+ function validateCommand$1(command, allowedCommandPrefixes, allowedReadRoots, workspaceDir) {
4619
6078
  const trimmed = command.trim();
4620
6079
  if (!trimmed) throw new Error("Command prefix not allowed: <empty>");
4621
- if (/[`]|[$][(]|[<][(]|[>][(]/.test(trimmed)) throw new Error("Command contains disallowed shell syntax");
4622
- if (/[<>]/.test(trimmed)) throw new Error("Command redirection is not allowed");
4623
- const segments = splitCommandSegments$1(trimmed);
4624
- if (segments.length === 0) throw new Error("Command prefix not allowed: <empty>");
4625
- for (const segment of segments) {
4626
- const prefix = extractCommandPrefix$1(segment);
4627
- if (!prefix || !allowedCommandPrefixes.includes(prefix)) throw new Error(`Command prefix not allowed: ${prefix || "<empty>"}`);
6080
+ if (/[;&|`$()<>]/.test(trimmed) || /[\r\n]/.test(trimmed)) throw new Error("Command contains disallowed shell syntax");
6081
+ if (/["'\\]/.test(trimmed)) throw new Error("Quoted/escaped shell syntax is not allowed");
6082
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
6083
+ if (tokens.length === 0) throw new Error("Command prefix not allowed: <empty>");
6084
+ const prefix = extractCommandPrefix$1(trimmed);
6085
+ if (!prefix || !allowedCommandPrefixes.includes(prefix)) throw new Error(`Command prefix not allowed: ${prefix || "<empty>"}`);
6086
+ if (![
6087
+ "cat",
6088
+ "head",
6089
+ "tail",
6090
+ "wc",
6091
+ "rg",
6092
+ "ls",
6093
+ "find"
6094
+ ].includes(prefix)) return;
6095
+ const readRoots = buildEffectiveRoots$1(allowedReadRoots, workspaceDir);
6096
+ const operands = collectReadPathOperands$1(prefix, tokens.slice(1)).filter(isLikelyPathOperand$1);
6097
+ for (const operand of operands) {
6098
+ const resolved = resolveToolPath$1(operand, workspaceDir);
6099
+ if (!startsWithAnyRoot$1(resolved, readRoots)) throw new Error(`Command path is outside allowed read roots: ${resolved}. Allowed roots: ${readRoots.join(", ") || "<none>"}`);
4628
6100
  }
4629
6101
  }
4630
6102
  function maybeTruncate$1(value, maxOutputBytes) {
@@ -4845,7 +6317,8 @@ var ContainerRuntime = class {
4845
6317
  }
4846
6318
  async exec(command, limits) {
4847
6319
  const effective = this.mergeLimits(limits);
4848
- validateCommand$1(command, effective.allowedCommandPrefixes);
6320
+ const workspaceDir = getRuntimeSessionContext()?.workspaceDir;
6321
+ validateCommand$1(command, effective.allowedCommandPrefixes, effective.allowedReadRoots, workspaceDir);
4849
6322
  return this.withContainer(effective, async (container) => {
4850
6323
  return this.runDocker([
4851
6324
  "exec",
@@ -4985,19 +6458,67 @@ function extractCommandPrefix(command) {
4985
6458
  if (!trimmed) return "";
4986
6459
  return trimmed.split(/\s+/)[0] ?? "";
4987
6460
  }
4988
- function splitCommandSegments(command) {
4989
- return command.split(/(?:&&|\|\||[;|\n])/).map((segment) => segment.trim()).filter(Boolean);
6461
+ function nonOptionArgs(args) {
6462
+ const out = [];
6463
+ let skipNext = false;
6464
+ for (const arg of args) {
6465
+ if (skipNext) {
6466
+ skipNext = false;
6467
+ continue;
6468
+ }
6469
+ if (arg === "--") break;
6470
+ if (arg === "-n" || arg === "-c" || arg === "--max-depth" || arg === "--glob" || arg === "--iglob" || arg === "--type" || arg === "--type-not" || arg === "-e" || arg === "-f") {
6471
+ skipNext = true;
6472
+ continue;
6473
+ }
6474
+ if (arg.startsWith("-")) continue;
6475
+ out.push(arg);
6476
+ }
6477
+ return out;
6478
+ }
6479
+ function isLikelyPathOperand(value) {
6480
+ const trimmed = value.trim();
6481
+ if (!trimmed) return false;
6482
+ if (/^\d+$/.test(trimmed)) return false;
6483
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return false;
6484
+ if (trimmed === "." || trimmed === ".." || trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~/") || trimmed.includes("/")) return true;
6485
+ return /^[A-Za-z0-9._-]+$/.test(trimmed);
6486
+ }
6487
+ function collectReadPathOperands(prefix, args) {
6488
+ if (prefix === "rg") {
6489
+ const plain = nonOptionArgs(args);
6490
+ if (plain.length <= 1) return [];
6491
+ return plain.slice(1);
6492
+ }
6493
+ if (prefix === "find") {
6494
+ const plain = nonOptionArgs(args);
6495
+ return plain.length > 0 ? [plain[0]] : [];
6496
+ }
6497
+ return nonOptionArgs(args);
4990
6498
  }
4991
- function validateCommand(command, allowedCommandPrefixes) {
6499
+ function validateCommand(command, allowedCommandPrefixes, allowedReadRoots, workspaceDir) {
4992
6500
  const trimmed = command.trim();
4993
6501
  if (!trimmed) throw new Error("Command prefix not allowed: <empty>");
4994
- if (/[`]|[$][(]|[<][(]|[>][(]/.test(trimmed)) throw new Error("Command contains disallowed shell syntax");
4995
- if (/[<>]/.test(trimmed)) throw new Error("Command redirection is not allowed");
4996
- const segments = splitCommandSegments(trimmed);
4997
- if (segments.length === 0) throw new Error("Command prefix not allowed: <empty>");
4998
- for (const segment of segments) {
4999
- const prefix = extractCommandPrefix(segment);
5000
- if (!prefix || !allowedCommandPrefixes.includes(prefix)) throw new Error(`Command prefix not allowed: ${prefix || "<empty>"}`);
6502
+ if (/[;&|`$()<>]/.test(trimmed) || /[\r\n]/.test(trimmed)) throw new Error("Command contains disallowed shell syntax");
6503
+ if (/["'\\]/.test(trimmed)) throw new Error("Quoted/escaped shell syntax is not allowed");
6504
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
6505
+ if (tokens.length === 0) throw new Error("Command prefix not allowed: <empty>");
6506
+ const prefix = extractCommandPrefix(trimmed);
6507
+ if (!prefix || !allowedCommandPrefixes.includes(prefix)) throw new Error(`Command prefix not allowed: ${prefix || "<empty>"}`);
6508
+ if (![
6509
+ "cat",
6510
+ "head",
6511
+ "tail",
6512
+ "wc",
6513
+ "rg",
6514
+ "ls",
6515
+ "find"
6516
+ ].includes(prefix)) return;
6517
+ const readRoots = buildEffectiveRoots(allowedReadRoots, workspaceDir);
6518
+ const operands = collectReadPathOperands(prefix, tokens.slice(1)).filter(isLikelyPathOperand);
6519
+ for (const operand of operands) {
6520
+ const resolved = resolveToolPath(operand, workspaceDir);
6521
+ if (!startsWithAnyRoot(resolved, readRoots)) throw new Error(`Command path is outside allowed read roots: ${resolved}. Allowed roots: ${readRoots.join(", ") || "<none>"}`);
5001
6522
  }
5002
6523
  }
5003
6524
  function maybeTruncate(value, maxOutputBytes) {
@@ -5023,13 +6544,18 @@ var LocalHostRuntime = class {
5023
6544
  }
5024
6545
  async exec(command, limits) {
5025
6546
  const effective = this.mergeLimits(limits);
5026
- validateCommand(command, effective.allowedCommandPrefixes);
6547
+ const workspaceDir = getRuntimeSessionContext()?.workspaceDir;
6548
+ validateCommand(command, effective.allowedCommandPrefixes, effective.allowedReadRoots, workspaceDir);
6549
+ const cwd = workspaceDir ? path.resolve(expandUserPath(workspaceDir)) : process.cwd();
5027
6550
  return await new Promise((resolve, reject) => {
5028
- const child = spawn("bash", ["-lc", command], { stdio: [
5029
- "ignore",
5030
- "pipe",
5031
- "pipe"
5032
- ] });
6551
+ const child = spawn("bash", ["-lc", command], {
6552
+ stdio: [
6553
+ "ignore",
6554
+ "pipe",
6555
+ "pipe"
6556
+ ],
6557
+ cwd
6558
+ });
5033
6559
  let stdout = "";
5034
6560
  let stderr = "";
5035
6561
  let timedOut = false;
@@ -5325,149 +6851,151 @@ function normalizeErrorMessage(error) {
5325
6851
  if (error instanceof Error) return error.message;
5326
6852
  return String(error);
5327
6853
  }
5328
- function createTools({ runtime, audit }) {
6854
+ function createTools({ runtime, audit, bashEnabled }) {
5329
6855
  const parser = new Parser();
5330
- return [
5331
- {
5332
- name: "bash",
5333
- label: "Bash",
5334
- description: "Run a shell command on allowed command prefixes only.",
5335
- parameters: Type.Object({
5336
- command: Type.String(),
5337
- timeoutMs: Type.Optional(Type.Number({
5338
- minimum: 1e3,
5339
- maximum: 12e4
5340
- }))
5341
- }),
5342
- execute: async (_toolCallId, params) => {
5343
- audit({
5344
- actor: "tool",
5345
- eventType: "tool.exec",
5346
- payload: { command: params.command }
5347
- });
5348
- const result = await runtime.exec(params.command, { timeoutMs: params.timeoutMs });
5349
- return textResult([
5350
- `exitCode: ${result.exitCode}`,
5351
- result.timedOut ? "timedOut: true" : "timedOut: false",
5352
- result.truncated ? "truncated: true" : "truncated: false",
5353
- "",
5354
- result.stdout ? `stdout:\n${result.stdout}` : "stdout: <empty>",
5355
- "",
5356
- result.stderr ? `stderr:\n${result.stderr}` : "stderr: <empty>"
5357
- ].join("\n"), result);
5358
- }
5359
- },
5360
- {
5361
- name: "read_file",
5362
- label: "Read File",
5363
- description: "Read a text file from an allowlisted path.",
5364
- parameters: Type.Object({
5365
- path: Type.String(),
5366
- maxBytes: Type.Optional(Type.Number({
5367
- minimum: 128,
5368
- maximum: 1e6
5369
- }))
5370
- }),
5371
- execute: async (_toolCallId, params) => {
5372
- audit({
5373
- actor: "tool",
5374
- eventType: "tool.read_file",
5375
- payload: { path: params.path }
5376
- });
5377
- const result = await runtime.readFile(params.path, { maxOutputBytes: params.maxBytes });
5378
- return textResult(result.content, result);
5379
- }
5380
- },
5381
- {
5382
- name: "write_file",
5383
- label: "Write File",
5384
- description: "Write UTF-8 text content to an allowlisted path.",
5385
- parameters: Type.Object({
5386
- path: Type.String(),
5387
- content: Type.String()
6856
+ const bashTool = {
6857
+ name: "bash",
6858
+ label: "Bash",
6859
+ description: "Run a shell command on allowed command prefixes only.",
6860
+ parameters: Type.Object({
6861
+ command: Type.String(),
6862
+ timeoutMs: Type.Optional(Type.Number({
6863
+ minimum: 1e3,
6864
+ maximum: 12e4
6865
+ }))
6866
+ }),
6867
+ execute: async (_toolCallId, params) => {
6868
+ audit({
6869
+ actor: "tool",
6870
+ eventType: "tool.exec",
6871
+ payload: { command: params.command }
6872
+ });
6873
+ const result = await runtime.exec(params.command, { timeoutMs: params.timeoutMs });
6874
+ return textResult([
6875
+ `exitCode: ${result.exitCode}`,
6876
+ result.timedOut ? "timedOut: true" : "timedOut: false",
6877
+ result.truncated ? "truncated: true" : "truncated: false",
6878
+ "",
6879
+ result.stdout ? `stdout:\n${result.stdout}` : "stdout: <empty>",
6880
+ "",
6881
+ result.stderr ? `stderr:\n${result.stderr}` : "stderr: <empty>"
6882
+ ].join("\n"), result);
6883
+ }
6884
+ };
6885
+ const readFileTool = {
6886
+ name: "read_file",
6887
+ label: "Read File",
6888
+ description: "Read a text file from an allowlisted path.",
6889
+ parameters: Type.Object({
6890
+ path: Type.String(),
6891
+ maxBytes: Type.Optional(Type.Number({
6892
+ minimum: 128,
6893
+ maximum: 1e6
6894
+ }))
6895
+ }),
6896
+ execute: async (_toolCallId, params) => {
6897
+ audit({
6898
+ actor: "tool",
6899
+ eventType: "tool.read_file",
6900
+ payload: { path: params.path }
6901
+ });
6902
+ const result = await runtime.readFile(params.path, { maxOutputBytes: params.maxBytes });
6903
+ return textResult(result.content, result);
6904
+ }
6905
+ };
6906
+ const writeFileTool = {
6907
+ name: "write_file",
6908
+ label: "Write File",
6909
+ description: "Write UTF-8 text content to an allowlisted path.",
6910
+ parameters: Type.Object({
6911
+ path: Type.String(),
6912
+ content: Type.String()
6913
+ }),
6914
+ execute: async (_toolCallId, params) => {
6915
+ audit({
6916
+ actor: "tool",
6917
+ eventType: "tool.write_file",
6918
+ payload: {
6919
+ path: params.path,
6920
+ bytes: Buffer.byteLength(params.content, "utf8")
6921
+ }
6922
+ });
6923
+ const result = await runtime.writeFile(params.path, params.content);
6924
+ return textResult(`Wrote ${result.bytesWritten} bytes to ${params.path}.`, result);
6925
+ }
6926
+ };
6927
+ const webSearchTool = {
6928
+ name: "web_search",
6929
+ label: "Web Search",
6930
+ description: "Fetch a URL and return readable article text.",
6931
+ parameters: Type.Object({ url: Type.String({ format: "uri" }) }),
6932
+ execute: async (_toolCallId, params) => {
6933
+ audit({
6934
+ actor: "tool",
6935
+ eventType: "tool.fetch_web",
6936
+ payload: { url: params.url }
6937
+ });
6938
+ const result = await runtime.fetchWeb(params.url);
6939
+ return textResult(`${result.title ? `# ${result.title}\n\n` : ""}${result.markdown}`.trim(), result);
6940
+ }
6941
+ };
6942
+ const fetchPodcastFeedTool = {
6943
+ name: "fetch_podcast_feed",
6944
+ label: "Fetch Podcast Feed",
6945
+ description: "Fetch one or more RSS podcast feeds and return recent episodes.",
6946
+ parameters: Type.Object({
6947
+ urls: Type.Array(Type.String({ format: "uri" }), {
6948
+ minItems: 1,
6949
+ maxItems: 20
5388
6950
  }),
5389
- execute: async (_toolCallId, params) => {
5390
- audit({
5391
- actor: "tool",
5392
- eventType: "tool.write_file",
5393
- payload: {
5394
- path: params.path,
5395
- bytes: Buffer.byteLength(params.content, "utf8")
5396
- }
6951
+ limitPerFeed: Type.Optional(Type.Number({
6952
+ minimum: 1,
6953
+ maximum: 50
6954
+ }))
6955
+ }),
6956
+ execute: async (_toolCallId, params) => {
6957
+ const limit = params.limitPerFeed ?? 5;
6958
+ const output = [];
6959
+ for (const url of params.urls) try {
6960
+ const feed = await parser.parseURL(url);
6961
+ const episodes = (feed.items ?? []).slice(0, limit).map((item) => ({
6962
+ title: item.title ?? "Untitled episode",
6963
+ publishedAt: item.pubDate || item.isoDate || null,
6964
+ link: item.link ?? "",
6965
+ summary: (item.contentSnippet || item.content || item.summary || "").replace(/\s+/g, " ").trim().slice(0, 400) || "No summary available."
6966
+ }));
6967
+ output.push({
6968
+ url,
6969
+ title: feed.title ?? "Untitled feed",
6970
+ episodes
5397
6971
  });
5398
- const result = await runtime.writeFile(params.path, params.content);
5399
- return textResult(`Wrote ${result.bytesWritten} bytes to ${params.path}.`, result);
5400
- }
5401
- },
5402
- {
5403
- name: "web_search",
5404
- label: "Web Search",
5405
- description: "Fetch a URL and return readable article text.",
5406
- parameters: Type.Object({ url: Type.String({ format: "uri" }) }),
5407
- execute: async (_toolCallId, params) => {
5408
- audit({
5409
- actor: "tool",
5410
- eventType: "tool.fetch_web",
5411
- payload: { url: params.url }
6972
+ } catch (error) {
6973
+ output.push({
6974
+ url,
6975
+ title: "Unknown feed",
6976
+ episodes: [],
6977
+ error: normalizeErrorMessage(error)
5412
6978
  });
5413
- const result = await runtime.fetchWeb(params.url);
5414
- return textResult(`${result.title ? `# ${result.title}\n\n` : ""}${result.markdown}`.trim(), result);
5415
6979
  }
5416
- },
5417
- {
5418
- name: "fetch_podcast_feed",
5419
- label: "Fetch Podcast Feed",
5420
- description: "Fetch one or more RSS podcast feeds and return recent episodes.",
5421
- parameters: Type.Object({
5422
- urls: Type.Array(Type.String({ format: "uri" }), {
5423
- minItems: 1,
5424
- maxItems: 20
5425
- }),
5426
- limitPerFeed: Type.Optional(Type.Number({
5427
- minimum: 1,
5428
- maximum: 50
5429
- }))
5430
- }),
5431
- execute: async (_toolCallId, params) => {
5432
- const limit = params.limitPerFeed ?? 5;
5433
- const output = [];
5434
- for (const url of params.urls) try {
5435
- const feed = await parser.parseURL(url);
5436
- const episodes = (feed.items ?? []).slice(0, limit).map((item) => ({
5437
- title: item.title ?? "Untitled episode",
5438
- publishedAt: item.pubDate || item.isoDate || null,
5439
- link: item.link ?? "",
5440
- summary: (item.contentSnippet || item.content || item.summary || "").replace(/\s+/g, " ").trim().slice(0, 400) || "No summary available."
5441
- }));
5442
- output.push({
5443
- url,
5444
- title: feed.title ?? "Untitled feed",
5445
- episodes
5446
- });
5447
- } catch (error) {
5448
- output.push({
5449
- url,
5450
- title: "Unknown feed",
5451
- episodes: [],
5452
- error: normalizeErrorMessage(error)
5453
- });
6980
+ audit({
6981
+ actor: "tool",
6982
+ eventType: "tool.fetch_podcast_feed",
6983
+ payload: {
6984
+ urls: params.urls,
6985
+ limitPerFeed: limit
5454
6986
  }
5455
- audit({
5456
- actor: "tool",
5457
- eventType: "tool.fetch_podcast_feed",
5458
- payload: {
5459
- urls: params.urls,
5460
- limitPerFeed: limit
5461
- }
5462
- });
5463
- return textResult(output.map((feed) => {
5464
- if (feed.error) return `Feed: ${feed.url}\nError: ${feed.error}`;
5465
- const episodesText = feed.episodes.map((episode, index) => `${index + 1}. ${episode.title}${episode.publishedAt ? ` (${episode.publishedAt})` : ""}\n${episode.link}\n${episode.summary}`).join("\n\n");
5466
- return `Feed: ${feed.title}\nSource: ${feed.url}\n\n${episodesText}`;
5467
- }).join("\n\n---\n\n"), output);
5468
- }
6987
+ });
6988
+ return textResult(output.map((feed) => {
6989
+ if (feed.error) return `Feed: ${feed.url}\nError: ${feed.error}`;
6990
+ const episodesText = feed.episodes.map((episode, index) => `${index + 1}. ${episode.title}${episode.publishedAt ? ` (${episode.publishedAt})` : ""}\n${episode.link}\n${episode.summary}`).join("\n\n");
6991
+ return `Feed: ${feed.title}\nSource: ${feed.url}\n\n${episodesText}`;
6992
+ }).join("\n\n---\n\n"), output);
5469
6993
  }
5470
- ];
6994
+ };
6995
+ const tools = [];
6996
+ if (bashEnabled) tools.push(bashTool);
6997
+ tools.push(readFileTool, writeFileTool, webSearchTool, fetchPodcastFeedTool);
6998
+ return tools;
5471
6999
  }
5472
7000
 
5473
7001
  //#endregion
@@ -5475,6 +7003,19 @@ function createTools({ runtime, audit }) {
5475
7003
  function normalizePrompt(msg) {
5476
7004
  return msg.text.trim();
5477
7005
  }
7006
+ function formatModelForSessionNotice(modelRef) {
7007
+ const match = modelRef.match(/^([^:\/]+)[:\/](.+)$/);
7008
+ if (!match) return modelRef;
7009
+ const provider = match[1];
7010
+ const model = match[2];
7011
+ if (!provider || !model) return modelRef;
7012
+ return `${provider}/${model}`;
7013
+ }
7014
+ function applyThinkingLevel(prompt, level, force = false) {
7015
+ if (/^Use (low|medium|high) reasoning effort for this task:/i.test(prompt)) return prompt;
7016
+ if (level === "medium" && !force) return prompt;
7017
+ return `Use ${level} reasoning effort for this task: ${prompt}`;
7018
+ }
5478
7019
  function buildChannelTarget(msg) {
5479
7020
  return {
5480
7021
  channel: msg.channel,
@@ -5510,6 +7051,17 @@ var MultiNotifier = class {
5510
7051
  }, text);
5511
7052
  }
5512
7053
  };
7054
+ function validateGatewaySecurityConfig() {
7055
+ if (!config.gateway.enabled) return;
7056
+ const auth = config.gateway.auth;
7057
+ if (auth.allowUnauthenticated) return;
7058
+ if (auth.token.trim() || auth.password.trim()) return;
7059
+ throw new Error([
7060
+ "Gateway auth is required when gateway is enabled.",
7061
+ "Set gateway.auth.token or gateway.auth.password in ~/.hovclaw/config.json,",
7062
+ "or explicitly set gateway.auth.allowUnauthenticated=true for insecure compatibility mode."
7063
+ ].join(" "));
7064
+ }
5513
7065
  async function main() {
5514
7066
  const importedLegacyEnv = ensureConfigFromLegacyEnv();
5515
7067
  if (!hasConfigFile()) {
@@ -5520,6 +7072,7 @@ async function main() {
5520
7072
  configPath: config.configPath,
5521
7073
  credentialsPath: config.credentialsPath
5522
7074
  }, "Imported legacy env configuration into ~/.hovclaw");
7075
+ validateGatewaySecurityConfig();
5523
7076
  try {
5524
7077
  const bootstrap = await ensureWorkspaceBootstrapForConfig(config);
5525
7078
  if (bootstrap.createdFileCount > 0) logger.info({
@@ -5550,7 +7103,8 @@ async function main() {
5550
7103
  });
5551
7104
  const agentManager = new PiAgentManager(db, createTools({
5552
7105
  runtime,
5553
- audit: (record) => db.appendAuditEvent(record)
7106
+ audit: (record) => db.appendAuditEvent(record),
7107
+ bashEnabled: config.runtime.tools.bashEnabled
5554
7108
  }));
5555
7109
  let lastMessageAt = null;
5556
7110
  const telegramPairingStore = new TelegramPairingStore(config.storeDir);
@@ -5561,10 +7115,19 @@ async function main() {
5561
7115
  const channels = channelPluginManager.initializeAdapters();
5562
7116
  const handleMessage = async (msg) => {
5563
7117
  lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
5564
- const normalizedPrompt = normalizePrompt(msg);
5565
- if (!normalizedPrompt) return;
5566
7118
  const channel = channels.get(msg.channel);
5567
7119
  if (!channel) return;
7120
+ const agentId = resolveAgentIdForInbound(config, msg);
7121
+ const sessionKey = composeSessionKey({
7122
+ agent: agentId,
7123
+ channel: msg.channel,
7124
+ identity: canonicalizeSessionIdentity(msg)
7125
+ });
7126
+ const target = buildChannelTarget(msg);
7127
+ let normalizedPrompt = normalizePrompt(msg);
7128
+ if (!normalizedPrompt) return;
7129
+ let thinkingLevel = config.commands.defaultThinkingLevel;
7130
+ let thinkingLevelForced = false;
5568
7131
  if (msg.channel === "telegram") {
5569
7132
  const policy = evaluateTelegramPolicy({
5570
7133
  config,
@@ -5573,12 +7136,13 @@ async function main() {
5573
7136
  });
5574
7137
  if (!policy.allowed) {
5575
7138
  if (policy.pairingCode && channel instanceof TelegramChannel) {
5576
- await channel.sendMessage(buildChannelTarget(msg), `Pairing required. Ask an approver to run: /pair approve ${policy.pairingCode}`);
7139
+ const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
7140
+ await channel.sendMessage(target, `Pairing required. Ask an approver to run: hovclaw pairing approve --channel telegram --account ${accountId} ${policy.pairingCode}`);
5577
7141
  db.appendAuditEvent({
5578
7142
  actor: "channel",
5579
7143
  eventType: "telegram.pair.pending",
5580
7144
  payload: {
5581
- accountId: msg.accountId ?? config.channels.telegram.defaultAccountId,
7145
+ accountId,
5582
7146
  userId: msg.userId,
5583
7147
  code: policy.pairingCode
5584
7148
  }
@@ -5587,20 +7151,36 @@ async function main() {
5587
7151
  return;
5588
7152
  }
5589
7153
  if (channel instanceof TelegramChannel) {
5590
- if (await handleTelegramCommand({
7154
+ const result = await handleTelegramCommand({
5591
7155
  msg,
5592
7156
  channel,
5593
7157
  db,
5594
- pairingStore: telegramPairingStore
5595
- })) return;
7158
+ agentId,
7159
+ sessionKey
7160
+ });
7161
+ if (result.resetSession) {
7162
+ try {
7163
+ await agentManager.resetSession(sessionKey);
7164
+ } catch (error) {
7165
+ logger.error({
7166
+ error,
7167
+ sessionKey
7168
+ }, "Failed to reset session");
7169
+ await channel.sendMessage(target, "Failed to reset session. Check logs and try again.");
7170
+ return;
7171
+ }
7172
+ const modelLabel = formatModelForSessionNotice(config.models.interactive);
7173
+ await channel.sendMessage(target, `New session started. Model: ${modelLabel}`);
7174
+ }
7175
+ if (result.promptOverride?.trim()) normalizedPrompt = result.promptOverride.trim();
7176
+ if (result.thinkingLevelOverride) {
7177
+ thinkingLevel = result.thinkingLevelOverride;
7178
+ thinkingLevelForced = true;
7179
+ }
7180
+ if (result.handled) return;
5596
7181
  }
5597
7182
  }
5598
- const sessionKey = composeSessionKey({
5599
- agent: resolveAgentIdForInbound(config, msg),
5600
- channel: msg.channel,
5601
- identity: canonicalizeSessionIdentity(msg)
5602
- });
5603
- const target = buildChannelTarget(msg);
7183
+ normalizedPrompt = applyThinkingLevel(normalizedPrompt, thinkingLevel, thinkingLevelForced);
5604
7184
  try {
5605
7185
  await channel.setTyping?.(target, true);
5606
7186
  let finalText = null;
@@ -5650,6 +7230,18 @@ async function main() {
5650
7230
  channel.onMessage(handleMessage);
5651
7231
  try {
5652
7232
  await channel.start();
7233
+ if (channel instanceof TelegramChannel) {
7234
+ const commands = listTelegramNativeCommandSpecs(config, channel.getAccountId());
7235
+ try {
7236
+ await channel.setMyCommands(commands);
7237
+ } catch (commandError) {
7238
+ logger.warn({
7239
+ error: commandError,
7240
+ channel: channelName,
7241
+ commandCount: commands.length
7242
+ }, "Telegram command registration failed; continuing without native command menu sync");
7243
+ }
7244
+ }
5653
7245
  } catch (error) {
5654
7246
  logger.error({
5655
7247
  error,
@@ -5689,6 +7281,7 @@ async function main() {
5689
7281
  gatewayServer?.start();
5690
7282
  const healthPortRaw = process.env.HEALTH_PORT || "8787";
5691
7283
  const healthPort = Number(healthPortRaw);
7284
+ const healthHost = (process.env.HEALTH_HOST || "127.0.0.1").trim() || "127.0.0.1";
5692
7285
  const healthServer = Number.isFinite(healthPort) && healthPort > 0 ? createServer((req, res) => {
5693
7286
  if (req.url !== "/health") {
5694
7287
  res.statusCode = 404;
@@ -5709,8 +7302,11 @@ async function main() {
5709
7302
  port: config.gateway.port
5710
7303
  }
5711
7304
  }));
5712
- }).listen(healthPort, () => {
5713
- logger.info({ healthPort }, "Health endpoint started");
7305
+ }).listen(healthPort, healthHost, () => {
7306
+ logger.info({
7307
+ healthHost,
7308
+ healthPort
7309
+ }, "Health endpoint started");
5714
7310
  }) : null;
5715
7311
  const shutdown = async () => {
5716
7312
  logger.info("Shutting down...");