@shadowob/connector 1.1.3-dev.261 → 1.1.3-dev.281

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/README.md CHANGED
@@ -26,6 +26,14 @@ npx @shadowob/connector@latest connect \
26
26
 
27
27
  Use `--dry-run` to preview writes and commands. Use `--json` with `plan` when embedding the plan in another tool.
28
28
 
29
+ `connect` merges existing configuration instead of replacing it:
30
+
31
+ - OpenClaw JSON defaults to `~/.shadowob/openclaw.json` or `--openclaw-config`.
32
+ - Hermes updates `~/.hermes/.env` and merges `~/.hermes/config.yaml`.
33
+ - cc-connect merges the ShadowOB platform into `~/.cc-connect/config.toml`.
34
+
35
+ Existing model providers, plugins, projects, platforms, and unrelated keys are preserved.
36
+
29
37
  ## OpenClaw
30
38
 
31
39
  ```bash
@@ -57,7 +65,7 @@ npx @shadowob/connector@latest connect \
57
65
 
58
66
  The Hermes plugin is bundled in `hermes-shadowob-plugin/`. The connector copies it to `~/.hermes/plugins/shadowob`, writes the Shadow token/base URL, and enables the plugin.
59
67
 
60
- Hermes does not need `agentId` or `channelId` in the setup command. The plugin calls `/api/auth/me` to resolve the Buddy agent id, then `/api/agents/:id/config` to receive channel access policy dynamically, matching the OpenClaw plugin behavior.
68
+ Hermes does not need `agentId` or `channelId` in the setup command. The plugin calls `/api/auth/me` to resolve the Buddy agent id, then `/api/agents/:id/config` to receive channel access policy dynamically, matching the OpenClaw plugin behavior. If no channel is available yet, Hermes stays online and waits for a DM, server join, channel membership, or policy update. By default it creates/uses the DM with the Buddy owner as the home channel.
61
69
 
62
70
  Manual config shape:
63
71
 
@@ -148,7 +156,7 @@ pnpm -C packages/connector build
148
156
  uv run --project .tmp/hermes-agent --with pytest python -m pytest packages/connector/hermes-shadowob-plugin/tests
149
157
  ```
150
158
 
151
- The tests cover plan generation for OpenClaw, Hermes, and cc-connect, and assert that Hermes setup no longer emits static `agentId` or `channelId` arguments.
159
+ The tests cover plan generation, config merging for OpenClaw/Hermes/cc-connect, and Hermes dynamic channel behavior without static `agentId` or `channelId` arguments.
152
160
 
153
161
  ## Capability Coverage
154
162
 
package/dist/cli.js CHANGED
@@ -7,6 +7,182 @@ import { homedir } from "os";
7
7
  import { dirname, resolve } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
 
10
+ // src/config-writers.ts
11
+ import { parse as parseDotenv } from "dotenv";
12
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
13
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
14
+ var SHADOW_ENV_VALUES = {
15
+ SHADOW_ALLOW_ALL_USERS: "true",
16
+ SHADOW_HEARTBEAT_INTERVAL_SECONDS: "30",
17
+ SHADOW_SLASH_COMMANDS_JSON: "[]"
18
+ };
19
+ function isRecord(value) {
20
+ return !!value && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+ function asRecord(value) {
23
+ return isRecord(value) ? { ...value } : {};
24
+ }
25
+ function uniqueStrings(values, required) {
26
+ return [
27
+ .../* @__PURE__ */ new Set([...values.filter((value) => typeof value === "string"), required])
28
+ ];
29
+ }
30
+ function ensureTrailingNewline(value) {
31
+ return value.endsWith("\n") ? value : `${value}
32
+ `;
33
+ }
34
+ function quoteEnv(value) {
35
+ return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : JSON.stringify(value);
36
+ }
37
+ function normalizeJsonRoot(existing, label) {
38
+ if (!existing.trim()) return {};
39
+ const parsed = JSON.parse(existing);
40
+ if (!isRecord(parsed)) {
41
+ throw new Error(`${label} config must be a JSON object`);
42
+ }
43
+ return { ...parsed };
44
+ }
45
+ function parseYamlRoot(existing, label) {
46
+ if (!existing.trim()) return {};
47
+ const parsed = parseYaml(existing);
48
+ if (parsed == null) return {};
49
+ if (!isRecord(parsed)) {
50
+ throw new Error(`${label} config must be a YAML object`);
51
+ }
52
+ return { ...parsed };
53
+ }
54
+ function parseTomlRoot(existing, label) {
55
+ if (!existing.trim()) return {};
56
+ const parsed = parseToml(existing);
57
+ if (!isRecord(parsed)) {
58
+ throw new Error(`${label} config must be a TOML table`);
59
+ }
60
+ return parsed;
61
+ }
62
+ function mergeEnvContent(existing, values) {
63
+ parseDotenv(existing);
64
+ const updates = {
65
+ SHADOW_BASE_URL: values.serverUrl,
66
+ SHADOW_TOKEN: values.token,
67
+ ...SHADOW_ENV_VALUES
68
+ };
69
+ const seen = /* @__PURE__ */ new Set();
70
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
71
+ const next = [];
72
+ for (const line of lines) {
73
+ const match = line.match(/^(\s*(?:export\s+)?)((?:[A-Za-z_][A-Za-z0-9_]*))\s*=/);
74
+ const key = match?.[2];
75
+ if (!key || !(key in updates)) {
76
+ next.push(line);
77
+ continue;
78
+ }
79
+ if (seen.has(key)) continue;
80
+ seen.add(key);
81
+ next.push(`${match[1] ?? ""}${key}=${quoteEnv(updates[key] ?? "")}`);
82
+ }
83
+ for (const [key, value] of Object.entries(updates)) {
84
+ if (!seen.has(key)) next.push(`${key}=${quoteEnv(value)}`);
85
+ }
86
+ while (next.length > 0 && next[next.length - 1] === "") next.pop();
87
+ return ensureTrailingNewline(next.join("\n"));
88
+ }
89
+ function mergeOpenClawConfigContent(existing, values) {
90
+ const root = normalizeJsonRoot(existing, "OpenClaw");
91
+ const channels = asRecord(root.channels);
92
+ const legacyShadow = asRecord(channels["openclaw-shadowob"]);
93
+ const shadow = asRecord(channels.shadowob);
94
+ channels.shadowob = {
95
+ ...legacyShadow,
96
+ ...shadow,
97
+ token: values.token,
98
+ serverUrl: values.serverUrl
99
+ };
100
+ delete channels["openclaw-shadowob"];
101
+ root.channels = channels;
102
+ const plugins = asRecord(root.plugins);
103
+ plugins.enabled = plugins.enabled ?? true;
104
+ plugins.allow = uniqueStrings(
105
+ Array.isArray(plugins.allow) ? plugins.allow : [],
106
+ "openclaw-shadowob"
107
+ );
108
+ const entries = asRecord(plugins.entries);
109
+ entries["openclaw-shadowob"] = {
110
+ ...asRecord(entries["openclaw-shadowob"]),
111
+ enabled: true
112
+ };
113
+ plugins.entries = entries;
114
+ root.plugins = plugins;
115
+ return ensureTrailingNewline(JSON.stringify(root, null, 2));
116
+ }
117
+ function mergeHermesConfigContent(existing, values) {
118
+ const root = parseYamlRoot(existing, "Hermes");
119
+ const plugins = asRecord(root.plugins);
120
+ plugins.enabled = uniqueStrings(Array.isArray(plugins.enabled) ? plugins.enabled : [], "shadowob");
121
+ root.plugins = plugins;
122
+ const platforms = asRecord(root.platforms);
123
+ const shadowob = asRecord(platforms.shadowob);
124
+ const extra = asRecord(shadowob.extra);
125
+ platforms.shadowob = {
126
+ ...shadowob,
127
+ enabled: true,
128
+ token: values.token,
129
+ extra: {
130
+ mention_only: false,
131
+ rest_only: false,
132
+ catchup_minutes: 0,
133
+ download_media: true,
134
+ slash_commands: [],
135
+ ...extra,
136
+ base_url: values.serverUrl
137
+ }
138
+ };
139
+ root.platforms = platforms;
140
+ return ensureTrailingNewline(stringifyYaml(root));
141
+ }
142
+ function asTomlTable(value) {
143
+ return isRecord(value) && !Array.isArray(value) ? { ...value } : {};
144
+ }
145
+ function tomlArray(value) {
146
+ if (!Array.isArray(value)) return [];
147
+ const tables = [];
148
+ for (const item of value) {
149
+ if (isRecord(item)) tables.push({ ...item });
150
+ }
151
+ return tables;
152
+ }
153
+ function mergeCcConnectConfigContent(existing, values) {
154
+ const root = parseTomlRoot(existing, "cc-connect");
155
+ const projects = tomlArray(root.projects);
156
+ let project = projects.find((item) => item.name === values.projectName) ?? projects.find((item) => item.work_dir === values.workDir);
157
+ if (!project) {
158
+ project = {};
159
+ projects.push(project);
160
+ }
161
+ project.name = values.projectName;
162
+ project.work_dir = values.workDir;
163
+ project.agent_type = values.agentType;
164
+ const platforms = tomlArray(project.platforms);
165
+ let shadowPlatform = platforms.find((item) => item.type === "shadowob");
166
+ if (!shadowPlatform) {
167
+ shadowPlatform = {};
168
+ platforms.push(shadowPlatform);
169
+ }
170
+ const options = asTomlTable(shadowPlatform.options);
171
+ shadowPlatform.type = "shadowob";
172
+ shadowPlatform.options = {
173
+ allow_from: "*",
174
+ listen_dms: true,
175
+ share_session_in_channel: false,
176
+ progress_style: "compact",
177
+ ...options,
178
+ token: values.token,
179
+ server_url: values.serverUrl
180
+ };
181
+ project.platforms = platforms;
182
+ root.projects = projects;
183
+ return ensureTrailingNewline(stringifyToml(root));
184
+ }
185
+
10
186
  // src/index.ts
11
187
  var DEFAULT_SERVER_URL = "https://shadowob.com";
12
188
  var DEFAULT_WORK_DIR = ".";
@@ -286,6 +462,7 @@ function usage() {
286
462
  " shadowob-connector connect --target <openclaw|hermes|cc-connect> --server-url <url> --token <token>",
287
463
  "",
288
464
  "Options:",
465
+ " --openclaw-config <path> OpenClaw JSON config, default $OPENCLAW_CONFIG or ~/.shadowob/openclaw.json",
289
466
  " --hermes-home <path> Hermes config directory, default $HERMES_HOME or ~/.hermes",
290
467
  " --work-dir <path> cc-connect project work directory",
291
468
  " --project-name <name> cc-connect project name",
@@ -316,6 +493,7 @@ function parseArgs(args) {
316
493
  target,
317
494
  serverUrl: readOption(optionArgs, "--server-url") ?? "https://shadowob.com",
318
495
  token: readOption(optionArgs, "--token") ?? "",
496
+ openclawConfig: readOption(optionArgs, "--openclaw-config"),
319
497
  hermesHome: readOption(optionArgs, "--hermes-home"),
320
498
  workDir: readOption(optionArgs, "--work-dir"),
321
499
  projectName: readOption(optionArgs, "--project-name"),
@@ -363,25 +541,19 @@ function writeFile(path, content, dryRun) {
363
541
  writeFileSync(path, content.endsWith("\n") ? content : `${content}
364
542
  `);
365
543
  }
366
- function upsertManagedBlock(path, name, content, dryRun) {
367
- const begin = `# BEGIN ShadowOB ${name}`;
368
- const end = `# END ShadowOB ${name}`;
369
- const block = `${begin}
370
- ${content}
371
- ${end}`;
372
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
373
- const pattern = new RegExp(
374
- `${begin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
375
- );
376
- const next = existing.match(pattern) ? existing.replace(pattern, block) : [existing.trimEnd(), block].filter(Boolean).join("\n\n");
377
- writeFile(path, next, dryRun);
378
- }
379
544
  function packageRoot() {
380
545
  return resolve(dirname(fileURLToPath(import.meta.url)), "..");
381
546
  }
382
547
  function expandHome(value) {
383
548
  return value.startsWith("~/") ? resolve(homedir(), value.slice(2)) : resolve(value);
384
549
  }
550
+ function readExisting(path) {
551
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
552
+ }
553
+ function normalizeServerUrl2(value) {
554
+ const trimmed = value.trim() || "https://shadowob.com";
555
+ return trimmed.endsWith("/api") ? trimmed.slice(0, -4) : trimmed.replace(/\/$/, "");
556
+ }
385
557
  function hermesPluginSource() {
386
558
  const candidates = [
387
559
  resolve(packageRoot(), "hermes-shadowob-plugin"),
@@ -393,9 +565,21 @@ function hermesPluginSource() {
393
565
  }
394
566
  function applyOpenClaw(options) {
395
567
  const plan = createConnectorPlan(options);
396
- for (const step of plan.commands) {
397
- console.log(`Applying: ${step.label}`);
398
- runShell(step.command, options.dryRun);
568
+ const configPath = expandHome(
569
+ options.openclawConfig ?? process.env.OPENCLAW_CONFIG ?? "~/.shadowob/openclaw.json"
570
+ );
571
+ console.log("Applying: Install plugin");
572
+ runShell("openclaw plugins install @shadowob/openclaw-shadowob", options.dryRun);
573
+ console.log(`Applying: Merge OpenClaw config ${configPath}`);
574
+ const next = mergeOpenClawConfigContent(readExisting(configPath), {
575
+ token: options.token,
576
+ serverUrl: normalizeServerUrl2(options.serverUrl)
577
+ });
578
+ writeFile(configPath, next, options.dryRun);
579
+ const restart = plan.commands.find((step) => step.label === "Restart gateway");
580
+ if (restart) {
581
+ console.log(`Applying: ${restart.label}`);
582
+ runShell(restart.command, options.dryRun);
399
583
  }
400
584
  }
401
585
  function applyHermes(options) {
@@ -404,23 +588,24 @@ function applyHermes(options) {
404
588
  const pluginTarget = resolve(hermesDir, "plugins/shadowob");
405
589
  const envPath = resolve(hermesDir, ".env");
406
590
  const configPath = resolve(hermesDir, "config.yaml");
407
- const generatedConfigPath = resolve(hermesDir, "config.shadowob.yaml");
408
591
  const envBlock = plan.configBlocks.find((block) => block.label === "~/.hermes/.env");
409
- const yamlBlock = plan.configBlocks.find((block) => block.label === "~/.hermes/config.yaml");
410
- if (!envBlock || !yamlBlock) throw new Error("Hermes plan is missing config blocks");
592
+ if (!envBlock) throw new Error("Hermes plan is missing config blocks");
411
593
  if (options.dryRun) {
412
594
  console.log(`[dry-run] copy ${hermesPluginSource()} -> ${pluginTarget}`);
413
595
  } else {
414
596
  mkdirSync(resolve(hermesDir, "plugins"), { recursive: true });
415
597
  cpSync(hermesPluginSource(), pluginTarget, { recursive: true, force: true });
416
598
  }
417
- upsertManagedBlock(envPath, "Hermes ShadowOB", envBlock.content, options.dryRun);
418
- if (!existsSync(configPath) || options.force) {
419
- writeFile(configPath, yamlBlock.content, options.dryRun);
420
- } else {
421
- writeFile(generatedConfigPath, yamlBlock.content, options.dryRun);
422
- console.log(`Existing Hermes config kept. Generated ShadowOB config: ${generatedConfigPath}`);
423
- }
599
+ const nextEnv = options.force ? envBlock.content : mergeEnvContent(readExisting(envPath), {
600
+ token: options.token,
601
+ serverUrl: normalizeServerUrl2(options.serverUrl)
602
+ });
603
+ writeFile(envPath, nextEnv, options.dryRun);
604
+ const nextConfig = mergeHermesConfigContent(options.force ? "" : readExisting(configPath), {
605
+ token: options.token,
606
+ serverUrl: normalizeServerUrl2(options.serverUrl)
607
+ });
608
+ writeFile(configPath, nextConfig, options.dryRun);
424
609
  if (options.install) {
425
610
  runShell(
426
611
  `python -m pip install -r "${resolve(pluginTarget, "requirements.txt")}"`,
@@ -437,13 +622,14 @@ function applyCcConnect(options) {
437
622
  const configBlock = plan.configBlocks.find((block) => block.label === "~/.cc-connect/config.toml");
438
623
  if (!configBlock) throw new Error("cc-connect plan is missing config block");
439
624
  const configPath = resolve(homedir(), ".cc-connect/config.toml");
440
- const generatedPath = resolve(homedir(), ".cc-connect/config.shadowob.toml");
441
- if (!existsSync(configPath) || options.force) {
442
- writeFile(configPath, configBlock.content, options.dryRun);
443
- } else {
444
- writeFile(generatedPath, configBlock.content, options.dryRun);
445
- console.log(`Existing cc-connect config kept. Generated ShadowOB config: ${generatedPath}`);
446
- }
625
+ const nextConfig = options.force ? configBlock.content : mergeCcConnectConfigContent(readExisting(configPath), {
626
+ token: options.token,
627
+ serverUrl: normalizeServerUrl2(options.serverUrl),
628
+ projectName: options.projectName?.trim() || "shadow-buddy",
629
+ workDir: options.workDir?.trim() || ".",
630
+ agentType: options.agentType?.trim() || "codex"
631
+ });
632
+ writeFile(configPath, nextConfig, options.dryRun);
447
633
  if (options.install) {
448
634
  runShell("npm install -g cc-connect", options.dryRun);
449
635
  }
@@ -150,8 +150,8 @@ This iteration fixes issues found during static review against the uploaded Shad
150
150
  - Fixed REST polling shutdown condition so the polling loop stops when the adapter is disconnected.
151
151
  - Fixed `env_enablement_fn` to return a flat seed dict, because Hermes merges all non-`home_channel` keys directly into `PlatformConfig.extra`.
152
152
  - Changed env auto-enable and connector setup to require only Shadow endpoint/token; Buddy id and channel policy are now resolved dynamically from Shadow.
153
- - Added dynamic handling for `channel:member-added` and `channel:member-removed` events.
154
- - Added dynamic handling for `server:joined` and `agent:policy-changed` events.
153
+ - Added OpenClaw-aligned dynamic handling for `channel:member-added`, `channel:member-removed`, `server:joined`, and `agent:policy-changed` events.
154
+ - Changed empty-channel startup from fatal to tolerant waiting. The adapter stays online, refreshes channel policy periodically, and uses the owner DM as the default home channel when available.
155
155
  - Updated standalone media delivery to support local paths, `MEDIA:` refs, relative paths, Shadow private URLs and remote URLs through the SDK helper.
156
156
 
157
157
  ## Known limits in this first version
@@ -465,6 +465,13 @@ def _remote_listen_channel_entries(
465
465
  return entries
466
466
 
467
467
 
468
+ def _owner_id_from_remote_config(remote_config: dict[str, Any] | None) -> str | None:
469
+ if not isinstance(remote_config, dict):
470
+ return None
471
+ owner_id = str(remote_config.get("ownerId") or remote_config.get("owner_id") or "").strip()
472
+ return owner_id or None
473
+
474
+
468
475
  class ShadowOBAdapter(BasePlatformAdapter):
469
476
  """Hermes ``BasePlatformAdapter`` implementation for Shadow."""
470
477
 
@@ -479,6 +486,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
479
486
  self.socket: ShadowSocketClient | None = None
480
487
  self._poll_task: asyncio.Task | None = None
481
488
  self._heartbeat_task: asyncio.Task | None = None
489
+ self._channel_refresh_task: asyncio.Task | None = None
482
490
  self._channel_ids: list[str] = _channel_ids_from_config(config)
483
491
  self._configured_channel_ids: set[str] = set(self._channel_ids)
484
492
  self._remote_channel_ids: set[str] = set()
@@ -532,26 +540,26 @@ class ShadowOBAdapter(BasePlatformAdapter):
532
540
  await self.client.open()
533
541
  await self._load_identity()
534
542
  await self._register_slash_commands()
535
- await self._start_heartbeat()
536
543
  await self._resolve_channels()
537
544
  if not self._channel_ids:
538
- self._set_fatal_error(
539
- "config_missing",
540
- "No Shadow channels are available for this Buddy token. Add the Buddy to a server/channel, open a DM, or verify the remote agent policy.",
541
- retryable=False,
545
+ logger.warning(
546
+ "[Shadow] No channels are available yet for this Buddy token. "
547
+ "Waiting for the Buddy to be added to a channel or DM.",
542
548
  )
543
- return False
544
549
 
545
- if self._rest_only:
546
- await self._start_polling()
547
- else:
550
+ use_polling = self._rest_only
551
+ if not self._rest_only:
548
552
  try:
549
553
  await self._start_socket()
550
554
  except Exception as exc:
551
555
  logger.warning("[Shadow] Socket.IO connection failed, falling back to REST polling: %s", exc)
552
- await self._start_polling()
556
+ use_polling = True
553
557
 
554
558
  self._mark_connected()
559
+ if use_polling:
560
+ await self._start_polling()
561
+ await self._start_heartbeat()
562
+ await self._start_channel_refresh()
555
563
  logger.info("[Shadow] Connected to %s; channels=%s", self.base_url, ",".join(self._channel_ids))
556
564
  return True
557
565
  except Exception as exc:
@@ -573,6 +581,13 @@ class ShadowOBAdapter(BasePlatformAdapter):
573
581
  except asyncio.CancelledError:
574
582
  pass
575
583
  self._heartbeat_task = None
584
+ if self._channel_refresh_task is not None and not self._channel_refresh_task.done():
585
+ self._channel_refresh_task.cancel()
586
+ try:
587
+ await self._channel_refresh_task
588
+ except asyncio.CancelledError:
589
+ pass
590
+ self._channel_refresh_task = None
576
591
  if self._poll_task is not None and not self._poll_task.done():
577
592
  self._poll_task.cancel()
578
593
  try:
@@ -909,19 +924,47 @@ class ShadowOBAdapter(BasePlatformAdapter):
909
924
  self._remote_channel_ids = new_remote_ids
910
925
  logger.info("[Shadow] Refreshed remote config for agent %s; channels=%s", self._agent_id, len(new_remote_ids))
911
926
 
912
- if sync_socket and self.socket is not None:
913
- next_channel_ids = set(self._channel_ids)
914
- for channel_id in old_channel_ids - next_channel_ids:
915
- try:
916
- await self.socket.leave_channel(channel_id)
917
- except Exception:
918
- pass
919
- for channel_id in next_channel_ids - old_channel_ids:
920
- try:
921
- ack = await self.socket.join_channel(channel_id)
922
- logger.info("[Shadow] Joined channel %s after config refresh ack=%s", channel_id, ack)
923
- except Exception as exc:
924
- logger.warning("[Shadow] Failed to join refreshed channel %s: %s", channel_id, exc)
927
+ if sync_socket:
928
+ await self._sync_socket_channels(old_channel_ids)
929
+
930
+ async def _sync_socket_channels(self, old_channel_ids: set[str]) -> None:
931
+ if self.socket is None:
932
+ return
933
+ next_channel_ids = set(self._channel_ids)
934
+ for channel_id in old_channel_ids - next_channel_ids:
935
+ try:
936
+ await self.socket.leave_channel(channel_id)
937
+ except Exception:
938
+ pass
939
+ for channel_id in next_channel_ids - old_channel_ids:
940
+ try:
941
+ ack = await self.socket.join_channel(channel_id)
942
+ logger.info("[Shadow] Joined channel %s after config refresh ack=%s", channel_id, ack)
943
+ except Exception as exc:
944
+ logger.warning("[Shadow] Failed to join refreshed channel %s: %s", channel_id, exc)
945
+
946
+ async def _ensure_owner_dm_home_channel(self) -> None:
947
+ if self.client is None:
948
+ return
949
+ owner_id = _owner_id_from_remote_config(self._remote_config)
950
+ if not owner_id or owner_id == self._bot_user_id:
951
+ return
952
+ try:
953
+ channel = await self.client.create_direct_channel(owner_id)
954
+ except Exception as exc:
955
+ logger.debug("[Shadow] Owner DM home channel is not available yet: %s", exc)
956
+ return
957
+ channel_id = str(channel.get("id") or "").strip()
958
+ if not channel_id:
959
+ return
960
+ self._channel_cache[channel_id] = {
961
+ **channel,
962
+ "kind": channel.get("kind") or channel.get("type") or "dm",
963
+ }
964
+ self._channel_policies.setdefault(channel_id, _default_policy_from_remote_config(self._remote_config))
965
+ if channel_id not in self._channel_ids:
966
+ self._channel_ids.append(channel_id)
967
+ logger.info("[Shadow] Using owner DM %s as the default home channel", channel_id)
925
968
 
926
969
  async def _register_slash_commands(self) -> None:
927
970
  if self.client is None or not self._agent_id or not self._slash_commands:
@@ -952,6 +995,26 @@ class ShadowOBAdapter(BasePlatformAdapter):
952
995
  except Exception as exc:
953
996
  logger.debug("[Shadow] heartbeat failed for agent %s: %s", self._agent_id, exc)
954
997
 
998
+ async def _start_channel_refresh(self) -> None:
999
+ if self._channel_refresh_task is None or self._channel_refresh_task.done():
1000
+ self._channel_refresh_task = asyncio.create_task(
1001
+ self._channel_refresh_loop(),
1002
+ name="shadowob-channel-refresh",
1003
+ )
1004
+
1005
+ async def _channel_refresh_loop(self) -> None:
1006
+ interval = max(10.0, min(60.0, self._heartbeat_interval))
1007
+ while self._running and not self.has_fatal_error:
1008
+ await asyncio.sleep(interval)
1009
+ try:
1010
+ await self._resolve_channels(sync_socket=True)
1011
+ if not self._channel_ids:
1012
+ logger.debug("[Shadow] Still waiting for a channel or owner DM")
1013
+ except asyncio.CancelledError:
1014
+ raise
1015
+ except Exception as exc:
1016
+ logger.debug("[Shadow] Channel refresh failed: %s", exc)
1017
+
955
1018
  async def _send_slash_interactive_prompt(
956
1019
  self,
957
1020
  match: tuple[dict[str, Any], str, str],
@@ -986,9 +1049,10 @@ class ShadowOBAdapter(BasePlatformAdapter):
986
1049
  logger.info("[Shadow] Sent interactive prompt for slash command /%s", name)
987
1050
  return True
988
1051
 
989
- async def _resolve_channels(self) -> None:
1052
+ async def _resolve_channels(self, *, sync_socket: bool = False) -> None:
990
1053
  if self.client is None:
991
1054
  return
1055
+ old_channel_ids = set(self._channel_ids)
992
1056
  if self._agent_id:
993
1057
  try:
994
1058
  await self._refresh_remote_config()
@@ -1044,6 +1108,8 @@ class ShadowOBAdapter(BasePlatformAdapter):
1044
1108
  except Exception as exc:
1045
1109
  logger.debug("[Shadow] Failed to list direct channels: %s", exc)
1046
1110
 
1111
+ await self._ensure_owner_dm_home_channel()
1112
+
1047
1113
  # Best-effort metadata cache for explicitly configured channels.
1048
1114
  for channel_id in list(self._channel_ids):
1049
1115
  if channel_id in self._channel_cache:
@@ -1053,6 +1119,9 @@ class ShadowOBAdapter(BasePlatformAdapter):
1053
1119
  except Exception:
1054
1120
  self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
1055
1121
 
1122
+ if sync_socket:
1123
+ await self._sync_socket_channels(old_channel_ids)
1124
+
1056
1125
  async def _start_socket(self) -> None:
1057
1126
  self.socket = ShadowSocketClient(self.base_url, self.token, transports=self._transports, logger=logger)
1058
1127
  self.socket.on("connect", self._on_socket_connect)
@@ -1070,13 +1139,27 @@ class ShadowOBAdapter(BasePlatformAdapter):
1070
1139
  self.socket.on("server:joined", self._on_server_joined)
1071
1140
  self.socket.on("agent:policy-changed", self._on_agent_policy_changed)
1072
1141
  await self.socket.connect()
1073
- await self.socket.update_presence("online")
1074
- for channel_id in self._channel_ids:
1075
- ack = await self.socket.join_channel(channel_id)
1076
- logger.info("[Shadow] Joined channel %s ack=%s", channel_id, ack)
1142
+ await self._join_current_socket_channels()
1077
1143
  if self._catchup_minutes > 0:
1078
1144
  await self._catchup_recent_messages()
1079
1145
 
1146
+ async def _join_current_socket_channels(self) -> None:
1147
+ if self.socket is None:
1148
+ return
1149
+ try:
1150
+ await self.socket.update_presence("online")
1151
+ except Exception as exc:
1152
+ logger.debug("[Shadow] Failed to update socket presence: %s", exc)
1153
+ if not self._channel_ids:
1154
+ logger.info("[Shadow] Socket connected with no channels yet; waiting for channel membership events")
1155
+ return
1156
+ for channel_id in list(self._channel_ids):
1157
+ try:
1158
+ ack = await self.socket.join_channel(channel_id)
1159
+ logger.info("[Shadow] Joined channel %s ack=%s", channel_id, ack)
1160
+ except Exception as exc:
1161
+ logger.warning("[Shadow] Failed to join channel %s: %s", channel_id, exc)
1162
+
1080
1163
  async def _start_polling(self) -> None:
1081
1164
  if self._catchup_minutes > 0:
1082
1165
  await self._catchup_recent_messages()
@@ -1142,6 +1225,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
1142
1225
 
1143
1226
  async def _on_socket_connect(self) -> None:
1144
1227
  logger.info("[Shadow] Socket connected")
1228
+ await self._join_current_socket_channels()
1145
1229
 
1146
1230
  async def _on_socket_disconnect(self, reason: str | None = None) -> None:
1147
1231
  logger.info("[Shadow] Socket disconnected: %s", reason)
@@ -1163,6 +1247,11 @@ class ShadowOBAdapter(BasePlatformAdapter):
1163
1247
  channel_id = str(payload.get("channelId") or payload.get("channel_id") or "").strip()
1164
1248
  if not channel_id:
1165
1249
  return
1250
+ old_channel_ids = set(self._channel_ids)
1251
+ try:
1252
+ await self._resolve_channels(sync_socket=False)
1253
+ except Exception as exc:
1254
+ logger.warning("[Shadow] Failed to refresh config after channel member add: %s", exc)
1166
1255
  if channel_id not in self._channel_ids:
1167
1256
  self._channel_ids.append(channel_id)
1168
1257
  if self.client is not None:
@@ -1170,10 +1259,13 @@ class ShadowOBAdapter(BasePlatformAdapter):
1170
1259
  self._channel_cache[channel_id] = await self.client.get_channel(channel_id)
1171
1260
  except Exception:
1172
1261
  self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
1262
+ self._channel_policies.setdefault(
1263
+ channel_id,
1264
+ _default_policy_from_remote_config(self._remote_config),
1265
+ )
1173
1266
  if self.socket is not None:
1174
1267
  try:
1175
- ack = await self.socket.join_channel(channel_id)
1176
- logger.info("[Shadow] Joined newly added channel %s ack=%s", channel_id, ack)
1268
+ await self._sync_socket_channels(old_channel_ids)
1177
1269
  except Exception as exc:
1178
1270
  logger.warning("[Shadow] Failed to join newly added channel %s: %s", channel_id, exc)
1179
1271
  if self._catchup_minutes > 0:
@@ -1200,7 +1292,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
1200
1292
  if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
1201
1293
  return
1202
1294
  try:
1203
- await self._refresh_remote_config(sync_socket=True)
1295
+ await self._resolve_channels(sync_socket=True)
1204
1296
  except Exception as exc:
1205
1297
  logger.warning("[Shadow] Failed to refresh remote config after server join: %s", exc)
1206
1298
 
@@ -1209,7 +1301,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
1209
1301
  if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
1210
1302
  return
1211
1303
  try:
1212
- await self._refresh_remote_config(sync_socket=True)
1304
+ await self._resolve_channels(sync_socket=True)
1213
1305
  except Exception as exc:
1214
1306
  logger.warning("[Shadow] Failed to refresh remote config after policy change: %s", exc)
1215
1307
 
@@ -231,6 +231,13 @@ class ShadowAsyncClient:
231
231
  async def list_direct_channels(self) -> list[JsonDict]:
232
232
  return await self.request("GET", "/api/channels/dm")
233
233
 
234
+ async def create_direct_channel(self, user_id: str) -> JsonDict:
235
+ return await self.request(
236
+ "POST",
237
+ "/api/channels/dm",
238
+ json_body={"userId": str(user_id)},
239
+ )
240
+
234
241
  async def get_channel(self, channel_id: str) -> JsonDict:
235
242
  return await self.request("GET", f"/api/channels/{quote(str(channel_id), safe='')}")
236
243
 
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
2
  import sys
3
+ import asyncio
3
4
 
4
5
  ROOT = Path(__file__).resolve().parents[1]
5
6
  if str(ROOT) not in sys.path:
@@ -83,6 +84,107 @@ def test_remote_config_entries_filter_listen_policy():
83
84
  assert entries[0][1]['serverId'] == 'server-1'
84
85
 
85
86
 
87
+ def test_resolve_channels_creates_owner_dm_home_channel_when_empty():
88
+ class FakeClient:
89
+ async def get_agent_config(self, agent_id):
90
+ assert agent_id == 'agent-1'
91
+ return {
92
+ 'agentId': 'agent-1',
93
+ 'botUserId': 'bot-1',
94
+ 'ownerId': 'owner-1',
95
+ 'servers': [],
96
+ }
97
+
98
+ async def create_direct_channel(self, user_id):
99
+ assert user_id == 'owner-1'
100
+ return {'id': 'dm-owner', 'kind': 'dm', 'name': 'Owner DM'}
101
+
102
+ class FakeSocket:
103
+ def __init__(self):
104
+ self.joined = []
105
+
106
+ async def join_channel(self, channel_id):
107
+ self.joined.append(channel_id)
108
+ return {'ok': True}
109
+
110
+ instance = adapter.ShadowOBAdapter.__new__(adapter.ShadowOBAdapter)
111
+ instance.client = FakeClient()
112
+ instance.socket = FakeSocket()
113
+ instance._agent_id = 'agent-1'
114
+ instance._bot_user_id = 'bot-1'
115
+ instance._slash_commands = []
116
+ instance._channel_ids = []
117
+ instance._configured_channel_ids = set()
118
+ instance._remote_channel_ids = set()
119
+ instance._channel_policies = {}
120
+ instance._remote_config = None
121
+ instance._channel_cache = {}
122
+ instance._server_ids = []
123
+ instance._auto_discover = False
124
+
125
+ asyncio.run(instance._resolve_channels(sync_socket=True))
126
+
127
+ assert instance._channel_ids == ['dm-owner']
128
+ assert instance._channel_cache['dm-owner']['kind'] == 'dm'
129
+ assert instance.socket.joined == ['dm-owner']
130
+
131
+
132
+ def test_member_added_refreshes_remote_config_and_joins_channel():
133
+ class FakeClient:
134
+ async def get_agent_config(self, agent_id):
135
+ assert agent_id == 'agent-1'
136
+ return {
137
+ 'agentId': 'agent-1',
138
+ 'botUserId': 'bot-1',
139
+ 'servers': [
140
+ {
141
+ 'id': 'server-1',
142
+ 'name': 'Server',
143
+ 'channels': [
144
+ {
145
+ 'id': 'channel-1',
146
+ 'name': 'general',
147
+ 'policy': {'listen': True, 'reply': True},
148
+ }
149
+ ],
150
+ }
151
+ ],
152
+ }
153
+
154
+ async def get_channel(self, channel_id):
155
+ return {'id': channel_id, 'kind': 'channel', 'name': 'general'}
156
+
157
+ class FakeSocket:
158
+ def __init__(self):
159
+ self.joined = []
160
+
161
+ async def join_channel(self, channel_id):
162
+ self.joined.append(channel_id)
163
+ return {'ok': True}
164
+
165
+ instance = adapter.ShadowOBAdapter.__new__(adapter.ShadowOBAdapter)
166
+ instance.client = FakeClient()
167
+ instance.socket = FakeSocket()
168
+ instance._agent_id = 'agent-1'
169
+ instance._bot_user_id = 'bot-1'
170
+ instance._slash_commands = []
171
+ instance._channel_ids = []
172
+ instance._configured_channel_ids = set()
173
+ instance._remote_channel_ids = set()
174
+ instance._channel_policies = {}
175
+ instance._remote_config = None
176
+ instance._channel_cache = {}
177
+ instance._server_ids = []
178
+ instance._auto_discover = False
179
+ instance._catchup_minutes = 0
180
+
181
+ asyncio.run(instance._on_channel_member_added({'channelId': 'channel-1'}))
182
+
183
+ assert instance._channel_ids == ['channel-1']
184
+ assert instance._channel_policies['channel-1']['reply'] is True
185
+ assert instance.socket.joined == ['channel-1']
186
+
187
+
86
188
  def test_slash_command_prompt_and_interactive_block():
87
189
  commands = [
88
190
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowob/connector",
3
- "version": "1.1.3-dev.261",
3
+ "version": "1.1.3-dev.281",
4
4
  "description": "Shadow connector helpers for OpenClaw, Hermes Agent, and cc-connect",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -47,6 +47,11 @@
47
47
  "publishConfig": {
48
48
  "access": "public"
49
49
  },
50
+ "dependencies": {
51
+ "dotenv": "^17.4.2",
52
+ "smol-toml": "^1.6.1",
53
+ "yaml": "^2.8.4"
54
+ },
50
55
  "scripts": {
51
56
  "build": "tsup",
52
57
  "dev": "tsup --watch",