@songsid/agend 2.1.4-beta.5 → 2.1.4-beta.51

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.
Files changed (175) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-endpoint.js +1 -1
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/backend/antigravity.d.ts +12 -8
  6. package/dist/backend/antigravity.js +189 -42
  7. package/dist/backend/antigravity.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +62 -23
  9. package/dist/backend/claude-code.js +296 -43
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/codex.d.ts +18 -0
  12. package/dist/backend/codex.js +148 -8
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/gemini-cli.js +2 -2
  15. package/dist/backend/gemini-cli.js.map +1 -1
  16. package/dist/backend/grok.js +2 -2
  17. package/dist/backend/grok.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +52 -2
  19. package/dist/backend/kiro.js +267 -12
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/opencode.d.ts +27 -11
  22. package/dist/backend/opencode.js +65 -41
  23. package/dist/backend/opencode.js.map +1 -1
  24. package/dist/backend/types.d.ts +97 -3
  25. package/dist/backend/types.js +12 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/backend-outage.d.ts +61 -0
  28. package/dist/backend-outage.js +71 -0
  29. package/dist/backend-outage.js.map +1 -0
  30. package/dist/channel/adapters/discord.d.ts +44 -2
  31. package/dist/channel/adapters/discord.js +526 -78
  32. package/dist/channel/adapters/discord.js.map +1 -1
  33. package/dist/channel/adapters/telegram.d.ts +30 -0
  34. package/dist/channel/adapters/telegram.js +221 -19
  35. package/dist/channel/adapters/telegram.js.map +1 -1
  36. package/dist/channel/agy-mcp-launcher.d.ts +2 -0
  37. package/dist/channel/agy-mcp-launcher.js +28 -0
  38. package/dist/channel/agy-mcp-launcher.js.map +1 -0
  39. package/dist/channel/ipc-bridge.d.ts +2 -1
  40. package/dist/channel/ipc-bridge.js +33 -4
  41. package/dist/channel/ipc-bridge.js.map +1 -1
  42. package/dist/channel/mcp-server.js +28 -25
  43. package/dist/channel/mcp-server.js.map +1 -1
  44. package/dist/channel/mcp-tools.js +4 -2
  45. package/dist/channel/mcp-tools.js.map +1 -1
  46. package/dist/channel/message-queue.js +62 -1
  47. package/dist/channel/message-queue.js.map +1 -1
  48. package/dist/channel/types.d.ts +31 -1
  49. package/dist/classic-channel-manager.d.ts +85 -3
  50. package/dist/classic-channel-manager.js +347 -34
  51. package/dist/classic-channel-manager.js.map +1 -1
  52. package/dist/cli.js +109 -37
  53. package/dist/cli.js.map +1 -1
  54. package/dist/config-validator.js +48 -7
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.d.ts +4 -0
  57. package/dist/config.js +12 -1
  58. package/dist/config.js.map +1 -1
  59. package/dist/cross-instance-envelope.d.ts +6 -0
  60. package/dist/cross-instance-envelope.js +30 -0
  61. package/dist/cross-instance-envelope.js.map +1 -0
  62. package/dist/daemon.d.ts +429 -9
  63. package/dist/daemon.js +1843 -289
  64. package/dist/daemon.js.map +1 -1
  65. package/dist/doctor.d.ts +41 -0
  66. package/dist/doctor.js +267 -0
  67. package/dist/doctor.js.map +1 -0
  68. package/dist/fleet-context.d.ts +54 -0
  69. package/dist/fleet-manager.d.ts +398 -11
  70. package/dist/fleet-manager.js +2756 -499
  71. package/dist/fleet-manager.js.map +1 -1
  72. package/dist/fleet-yaml-slim.d.ts +10 -0
  73. package/dist/fleet-yaml-slim.js +59 -0
  74. package/dist/fleet-yaml-slim.js.map +1 -0
  75. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  76. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  77. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  78. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  79. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  80. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  81. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  82. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  83. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  84. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  85. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  86. package/dist/general-knowledge/skills/session-management/SKILL.md +172 -8
  87. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  88. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  89. package/dist/instance-lifecycle.d.ts +99 -0
  90. package/dist/instance-lifecycle.js +339 -11
  91. package/dist/instance-lifecycle.js.map +1 -1
  92. package/dist/instructions.d.ts +12 -0
  93. package/dist/instructions.js +35 -1
  94. package/dist/instructions.js.map +1 -1
  95. package/dist/locale.js +1004 -288
  96. package/dist/locale.js.map +1 -1
  97. package/dist/login-flows.d.ts +91 -0
  98. package/dist/login-flows.js +170 -0
  99. package/dist/login-flows.js.map +1 -0
  100. package/dist/login-manager.d.ts +63 -0
  101. package/dist/login-manager.js +134 -0
  102. package/dist/login-manager.js.map +1 -0
  103. package/dist/network-family.d.ts +18 -0
  104. package/dist/network-family.js +20 -0
  105. package/dist/network-family.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +8 -0
  107. package/dist/outbound-handlers.js +101 -18
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +7 -1
  110. package/dist/outbound-schemas.js +9 -0
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/pane-input-residue.d.ts +52 -0
  113. package/dist/pane-input-residue.js +107 -0
  114. package/dist/pane-input-residue.js.map +1 -0
  115. package/dist/reply-dedup.d.ts +7 -8
  116. package/dist/reply-dedup.js +0 -0
  117. package/dist/reply-dedup.js.map +1 -1
  118. package/dist/restart-progress.d.ts +2 -0
  119. package/dist/restart-progress.js +3 -0
  120. package/dist/restart-progress.js.map +1 -1
  121. package/dist/scheduler/db.d.ts +12 -0
  122. package/dist/scheduler/db.js +59 -0
  123. package/dist/scheduler/db.js.map +1 -1
  124. package/dist/service-installer.d.ts +11 -0
  125. package/dist/service-installer.js +84 -18
  126. package/dist/service-installer.js.map +1 -1
  127. package/dist/settings-api.js +1 -1
  128. package/dist/settings-api.js.map +1 -1
  129. package/dist/setup-wizard.js +2 -2
  130. package/dist/setup-wizard.js.map +1 -1
  131. package/dist/spawn-gate.d.ts +30 -0
  132. package/dist/spawn-gate.js +79 -0
  133. package/dist/spawn-gate.js.map +1 -0
  134. package/dist/storm-window.d.ts +83 -0
  135. package/dist/storm-window.js +251 -0
  136. package/dist/storm-window.js.map +1 -0
  137. package/dist/tips.d.ts +44 -0
  138. package/dist/tips.js +355 -0
  139. package/dist/tips.js.map +1 -0
  140. package/dist/tmux-manager.d.ts +16 -0
  141. package/dist/tmux-manager.js +110 -25
  142. package/dist/tmux-manager.js.map +1 -1
  143. package/dist/tool-progress.d.ts +40 -0
  144. package/dist/tool-progress.js +289 -0
  145. package/dist/tool-progress.js.map +1 -0
  146. package/dist/topic-commands.d.ts +42 -4
  147. package/dist/topic-commands.js +372 -67
  148. package/dist/topic-commands.js.map +1 -1
  149. package/dist/transcript-monitor.d.ts +15 -2
  150. package/dist/transcript-monitor.js +63 -17
  151. package/dist/transcript-monitor.js.map +1 -1
  152. package/dist/transcript-sources.d.ts +131 -0
  153. package/dist/transcript-sources.js +580 -0
  154. package/dist/transcript-sources.js.map +1 -0
  155. package/dist/types.d.ts +13 -0
  156. package/dist/ui/dashboard.html +55 -32
  157. package/dist/ui/settings.html +200 -60
  158. package/dist/ui/view.html +147 -30
  159. package/dist/usage/format-rich.d.ts +1 -1
  160. package/dist/usage/format-rich.js +28 -24
  161. package/dist/usage/format-rich.js.map +1 -1
  162. package/dist/usage/i18n-keys.d.ts +7 -0
  163. package/dist/usage/i18n-keys.js +34 -0
  164. package/dist/usage/i18n-keys.js.map +1 -0
  165. package/dist/usage/i18n.d.ts +5 -0
  166. package/dist/usage/i18n.js +27 -0
  167. package/dist/usage/i18n.js.map +1 -0
  168. package/dist/usage/providers.d.ts +21 -0
  169. package/dist/usage/providers.js +153 -75
  170. package/dist/usage/providers.js.map +1 -1
  171. package/dist/usage/usage-api.d.ts +11 -3
  172. package/dist/usage/usage-api.js +61 -24
  173. package/dist/usage/usage-api.js.map +1 -1
  174. package/dist/workflow-templates/default.md +2 -1
  175. package/package.json +2 -2
@@ -20,6 +20,8 @@ export interface ClassicChannel {
20
20
  instanceName: string;
21
21
  backend?: string;
22
22
  model?: string;
23
+ displayName?: string;
24
+ description?: string;
23
25
  autoPauseAfter?: number;
24
26
  collab?: boolean;
25
27
  preTaskCommand?: string;
@@ -35,6 +37,15 @@ export interface ClassicChannel {
35
37
  * the historical name — single-bot users see no change across the upgrade.
36
38
  */
37
39
  export declare function classicInstanceName(sanitizedName: string, channelId: string, adapterId?: string): string;
40
+ /**
41
+ * Infer the platform which issued a Classic channel id.
42
+ *
43
+ * Telegram group ids are negative. Telegram private-chat ids are positive but
44
+ * fit in 52 bits (at most 16 decimal digits), while Discord snowflakes are
45
+ * currently 17-20 decimal digits. Anything else is deliberately left unknown
46
+ * so custom/future adapters retain the backwards-compatible primary fallback.
47
+ */
48
+ export declare function inferClassicChannelType(channelId: string): "telegram" | "discord" | undefined;
38
49
  /**
39
50
  * Manages classic bot channel lifecycle — register/unregister/persist.
40
51
  * Persists to ~/.agend/classicBot.yaml with per-channel backend override.
@@ -50,14 +61,26 @@ export declare class ClassicChannelManager {
50
61
  private defaults;
51
62
  private readonly configPath;
52
63
  private lastMtime;
53
- /** The primary (channels[0]) adapter id. Legacy entries migrate to it; it also names without a suffix. */
64
+ /** The primary (channels[0]) adapter id. It names without a suffix. */
54
65
  private primaryAdapterId?;
66
+ /** Config-order adapter identities, used to migrate legacy rows by platform. */
67
+ private adapters;
55
68
  constructor(dataDir: string, logger: Logger);
56
69
  /**
57
- * Record which adapter is primary. Migrates any legacy (adapterId-less)
58
- * entries onto it and rewrites the file in the new format. Idempotent.
70
+ * Backwards-compatible single-adapter configuration used by tests and older
71
+ * callers. Production startup supplies the complete adapter list through
72
+ * {@link configureAdapters} so legacy rows can be matched by platform.
59
73
  */
60
74
  setPrimaryAdapterId(adapterId: string): void;
75
+ /**
76
+ * Configure all adapters in fleet.yaml order, then migrate/repair persisted
77
+ * Classic registrations. The full list matters when the primary adapter is
78
+ * a different platform from a legacy channel.
79
+ */
80
+ configureAdapters(adapters: ReadonlyArray<{
81
+ id?: string;
82
+ type: string;
83
+ }>): void;
61
84
  /** Map key for a (channelId, adapterId) pair. adapterId-less = legacy entry (pre-migration). */
62
85
  private compositeKey;
63
86
  /** Rebuild the channelId presence set from the entry map (call after any mutation). */
@@ -68,6 +91,16 @@ export declare class ClassicChannelManager {
68
91
  * not-yet-migrated legacy entry as a defensive fallback.
69
92
  */
70
93
  private find;
94
+ /** Type of a configured adapter id, including historical default ids. */
95
+ private adapterType;
96
+ /** Deterministically choose the first configured adapter for a platform. */
97
+ private adapterForType;
98
+ /** Preserve the pre-repair file once; instance/workspace data is never deleted. */
99
+ private backupBeforeAdapterRepair;
100
+ /**
101
+ * Load the persisted registry. Returns true when it migrated/repaired data
102
+ * and the caller should write the normalized registry back to disk.
103
+ */
71
104
  private load;
72
105
  private save;
73
106
  /** Poll for external file changes (call periodically, e.g. every 30s) */
@@ -85,8 +118,57 @@ export declare class ClassicChannelManager {
85
118
  isUserAllowed(userId: string): boolean;
86
119
  /** Check if a user is admin. Empty/unset admin_users = no admins (secure default). */
87
120
  isAdmin(userId: string): boolean;
121
+ /**
122
+ * Grant access to a Discord guild / Telegram group, or promote a user to
123
+ * ClassicBot admin, and persist. Returns what happened so the caller can say
124
+ * so rather than claiming a change that did not occur.
125
+ *
126
+ * Ids are stored with String(): a Discord snowflake exceeds 2^53 and silently
127
+ * loses precision as a YAML integer, after which the strict `includes()` in
128
+ * the isAllowed checks stops matching it.
129
+ *
130
+ * "already-open" is not a no-op for tidiness — it is a guard. An empty
131
+ * allowed_guilds/allowed_groups means allow-all, so writing the FIRST entry
132
+ * would flip the fleet to an allow-list and lock out every other guild that
133
+ * works today. The caller asked to allow this one, not to restrict the rest.
134
+ */
135
+ private grantTo;
136
+ /**
137
+ * Coerce an existing list entry to the string form the isAllowed checks
138
+ * compare against — but only when that is lossless.
139
+ *
140
+ * A YAML number below 2^53 (every Telegram group id) round-trips exactly, so
141
+ * String() genuinely repairs it. A Discord snowflake does NOT: YAML already
142
+ * truncated it at parse time (1496407196106494055 arrives as ...494000), so
143
+ * String() would write a plausible-looking WRONG id back to disk and erase
144
+ * the one clue that something is broken — that the entry is a number rather
145
+ * than a quoted string. Leave those untouched and say so; nothing but the
146
+ * original text can recover the id.
147
+ */
148
+ private normalizeId;
149
+ /**
150
+ * Report ids YAML has already truncated, at load rather than on first write.
151
+ *
152
+ * String comparison in the isAllowed checks recovers an unquoted id below
153
+ * 2^53, so those need no warning. A Discord snowflake is different: YAML
154
+ * parsed it into a number that lost its last digits before this code ran, so
155
+ * no comparison can match it and nothing in the stored value can recover it.
156
+ * Without this the symptom is a guild that simply never gets in, with no
157
+ * error anywhere — which is the failure this change exists to remove.
158
+ */
159
+ private reportUnquotedIds;
160
+ /** Allow a Discord guild to use ClassicBot. */
161
+ allowGuild(guildId: string): "added" | "already" | "already-open";
162
+ /** Allow a Telegram group to use ClassicBot. */
163
+ allowGroup(groupId: string): "added" | "already" | "already-open";
164
+ /** Promote a user to ClassicBot admin (start/stop/model on classic channels). */
165
+ addAdminUser(userId: string): "added" | "already" | "already-open";
88
166
  /** Set the model override for the channel owning `instanceName` and persist. Returns true if found. */
89
167
  setModelByInstance(instanceName: string, model: string): boolean;
168
+ /** Persist agent-selected identity for a Classic instance. */
169
+ setDisplayNameByInstance(instanceName: string, displayName: string): boolean;
170
+ /** Persist agent-selected role/description for a Classic instance. */
171
+ setDescriptionByInstance(instanceName: string, description: string): boolean;
90
172
  /** Toggle collab mode for a channel. Returns new state. */
91
173
  toggleCollab(channelId: string, adapterId?: string): boolean;
92
174
  isCollab(channelId: string, adapterId?: string): boolean;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, readdirSync, unlinkSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, copyFileSync, readdirSync, unlinkSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import yaml from "js-yaml";
4
4
  import { getAgendHome } from "./paths.js";
@@ -48,6 +48,26 @@ export function classicInstanceName(sanitizedName, channelId, adapterId) {
48
48
  const base = `classic-${sanitizedName}-${suffix}`;
49
49
  return adapterId ? `${base}-${sanitizeInstanceName(adapterId)}` : base;
50
50
  }
51
+ /**
52
+ * Infer the platform which issued a Classic channel id.
53
+ *
54
+ * Telegram group ids are negative. Telegram private-chat ids are positive but
55
+ * fit in 52 bits (at most 16 decimal digits), while Discord snowflakes are
56
+ * currently 17-20 decimal digits. Anything else is deliberately left unknown
57
+ * so custom/future adapters retain the backwards-compatible primary fallback.
58
+ */
59
+ export function inferClassicChannelType(channelId) {
60
+ const value = channelId.trim();
61
+ if (!/^-?\d+$/.test(value))
62
+ return undefined;
63
+ if (value.startsWith("-"))
64
+ return "telegram";
65
+ if (value.length >= 17 && value.length <= 20)
66
+ return "discord";
67
+ if (value.length >= 1 && value.length <= 16)
68
+ return "telegram";
69
+ return undefined;
70
+ }
51
71
  /**
52
72
  * Manages classic bot channel lifecycle — register/unregister/persist.
53
73
  * Persists to ~/.agend/classicBot.yaml with per-channel backend override.
@@ -63,8 +83,10 @@ export class ClassicChannelManager {
63
83
  defaults = {};
64
84
  configPath;
65
85
  lastMtime = 0;
66
- /** The primary (channels[0]) adapter id. Legacy entries migrate to it; it also names without a suffix. */
86
+ /** The primary (channels[0]) adapter id. It names without a suffix. */
67
87
  primaryAdapterId;
88
+ /** Config-order adapter identities, used to migrate legacy rows by platform. */
89
+ adapters = [];
68
90
  constructor(dataDir, logger) {
69
91
  this.dataDir = dataDir;
70
92
  this.logger = logger;
@@ -72,17 +94,31 @@ export class ClassicChannelManager {
72
94
  this.load();
73
95
  }
74
96
  /**
75
- * Record which adapter is primary. Migrates any legacy (adapterId-less)
76
- * entries onto it and rewrites the file in the new format. Idempotent.
97
+ * Backwards-compatible single-adapter configuration used by tests and older
98
+ * callers. Production startup supplies the complete adapter list through
99
+ * {@link configureAdapters} so legacy rows can be matched by platform.
77
100
  */
78
101
  setPrimaryAdapterId(adapterId) {
79
- if (this.primaryAdapterId === adapterId)
102
+ this.configureAdapters([{ id: adapterId, type: adapterId }]);
103
+ }
104
+ /**
105
+ * Configure all adapters in fleet.yaml order, then migrate/repair persisted
106
+ * Classic registrations. The full list matters when the primary adapter is
107
+ * a different platform from a legacy channel.
108
+ */
109
+ configureAdapters(adapters) {
110
+ const identities = adapters.map(adapter => ({ id: adapter.id ?? adapter.type, type: adapter.type }));
111
+ const primaryAdapterId = identities[0]?.id;
112
+ const unchanged = this.primaryAdapterId === primaryAdapterId
113
+ && this.adapters.length === identities.length
114
+ && this.adapters.every((adapter, index) => adapter.id === identities[index]?.id && adapter.type === identities[index]?.type);
115
+ if (unchanged)
80
116
  return;
81
- this.primaryAdapterId = adapterId;
82
- const hadLegacy = [...this.channels.values()].some(ch => ch.adapterId === undefined);
83
- this.load(); // re-derive keys/names now that the primary id is known
84
- if (hadLegacy)
85
- this.save(); // persist the upgraded format once
117
+ this.primaryAdapterId = primaryAdapterId;
118
+ this.adapters = identities;
119
+ const repaired = this.load();
120
+ if (repaired && this.backupBeforeAdapterRepair())
121
+ this.save();
86
122
  }
87
123
  /** Map key for a (channelId, adapterId) pair. adapterId-less = legacy entry (pre-migration). */
88
124
  compositeKey(channelId, adapterId) {
@@ -102,50 +138,172 @@ export class ClassicChannelManager {
102
138
  if (exact)
103
139
  return exact;
104
140
  if (adapterId && adapterId === this.primaryAdapterId) {
105
- return this.channels.get(this.compositeKey(channelId, undefined));
141
+ const legacy = this.channels.get(this.compositeKey(channelId, undefined));
142
+ if (!legacy)
143
+ return undefined;
144
+ const inferredType = inferClassicChannelType(channelId);
145
+ const primaryType = this.adapterType(adapterId);
146
+ // A known cross-platform legacy row must wait for its owning adapter to
147
+ // return; never expose it to the current primary merely as a fallback.
148
+ if (inferredType && primaryType && inferredType !== primaryType)
149
+ return undefined;
150
+ return legacy;
106
151
  }
107
152
  return undefined;
108
153
  }
154
+ /** Type of a configured adapter id, including historical default ids. */
155
+ adapterType(adapterId) {
156
+ if (!adapterId)
157
+ return undefined;
158
+ return this.adapters.find(adapter => adapter.id === adapterId)?.type
159
+ ?? (adapterId === "telegram" || adapterId === "discord" ? adapterId : undefined);
160
+ }
161
+ /** Deterministically choose the first configured adapter for a platform. */
162
+ adapterForType(type) {
163
+ if (!type)
164
+ return undefined;
165
+ const primary = this.adapters.find(adapter => adapter.id === this.primaryAdapterId);
166
+ if (primary?.type === type)
167
+ return primary.id;
168
+ return this.adapters.find(adapter => adapter.type === type)?.id;
169
+ }
170
+ /** Preserve the pre-repair file once; instance/workspace data is never deleted. */
171
+ backupBeforeAdapterRepair() {
172
+ const backupPath = `${this.configPath}.pre-adapter-repair.bak`;
173
+ if (existsSync(backupPath) || !existsSync(this.configPath))
174
+ return true;
175
+ try {
176
+ copyFileSync(this.configPath, backupPath);
177
+ return true;
178
+ }
179
+ catch (err) {
180
+ this.logger.warn({ err, backupPath }, "Failed to back up classicBot.yaml before adapter repair");
181
+ return false;
182
+ }
183
+ }
184
+ /**
185
+ * Load the persisted registry. Returns true when it migrated/repaired data
186
+ * and the caller should write the normalized registry back to disk.
187
+ */
109
188
  load() {
110
189
  if (!existsSync(this.configPath))
111
- return;
190
+ return false;
112
191
  try {
113
192
  const raw = yaml.load(readFileSync(this.configPath, "utf-8"));
114
193
  if (!raw)
115
- return;
194
+ return false;
116
195
  this.defaults = raw.defaults ?? {};
196
+ this.reportUnquotedIds();
117
197
  this.channels.clear();
198
+ let repaired = false;
118
199
  if (raw.channels) {
200
+ const loaded = [];
119
201
  for (const [key, val] of Object.entries(raw.channels)) {
120
202
  // Old format: key IS the channelId, no adapterId/instanceName fields.
121
203
  const channelId = val.channelId ?? key;
122
- const adapterId = val.adapterId ?? this.primaryAdapterId; // migrate legacy → primary
204
+ const inferredType = inferClassicChannelType(channelId);
205
+ const inferredAdapterId = this.adapterForType(inferredType);
206
+ // Prefer the adapter whose platform issued the id. Unknown/custom ids
207
+ // retain the historical primary fallback.
208
+ const adapterId = val.adapterId
209
+ ?? (inferredType ? inferredAdapterId : this.primaryAdapterId);
123
210
  const name = val.name ?? channelId;
124
- // Non-primary adapters carry a suffix; primary/legacy keep the historical name.
125
- const suffixAdapter = adapterId && adapterId !== this.primaryAdapterId ? adapterId : undefined;
211
+ // An explicit non-primary registration carries a suffix. A legacy
212
+ // registration always keeps its historical unsuffixed instance name
213
+ // even when platform inference moves it away from channels[0].
214
+ const suffixAdapter = val.adapterId && adapterId !== this.primaryAdapterId ? adapterId : undefined;
126
215
  const instanceName = val.instanceName ?? classicInstanceName(sanitizeInstanceName(name), channelId, suffixAdapter);
127
- this.channels.set(this.compositeKey(channelId, adapterId), {
128
- channelId,
129
- adapterId,
130
- name,
131
- instanceName,
132
- backend: val.backend,
133
- model: val.model,
134
- autoPauseAfter: val.auto_pause_after,
135
- collab: val.collab,
136
- preTaskCommand: val.pre_task_command,
137
- contextLines: val.context_lines,
138
- createdAt: val.createdAt ?? "",
139
- createdBy: val.createdBy ?? "",
216
+ loaded.push({
217
+ persistedAdapterId: val.adapterId,
218
+ inferredType,
219
+ channel: {
220
+ channelId,
221
+ adapterId,
222
+ name,
223
+ instanceName,
224
+ backend: val.backend,
225
+ model: val.model,
226
+ displayName: val.display_name,
227
+ description: val.description,
228
+ autoPauseAfter: val.auto_pause_after,
229
+ collab: val.collab,
230
+ preTaskCommand: val.pre_task_command,
231
+ contextLines: val.context_lines,
232
+ createdAt: val.createdAt ?? "",
233
+ createdBy: val.createdBy ?? "",
234
+ },
140
235
  });
141
236
  }
237
+ for (const entry of loaded) {
238
+ const { channel, persistedAdapterId, inferredType } = entry;
239
+ const inferredAdapterId = this.adapterForType(inferredType);
240
+ const persistedType = this.adapterType(persistedAdapterId);
241
+ const isLegacy = persistedAdapterId === undefined;
242
+ const isCrossPlatformMisbind = !!(persistedAdapterId
243
+ && inferredType
244
+ && inferredAdapterId
245
+ && persistedType
246
+ && persistedType !== inferredType);
247
+ if (isLegacy && channel.adapterId) {
248
+ repaired = true;
249
+ this.logger.warn({ channelId: channel.channelId, adapterId: channel.adapterId, instanceName: channel.instanceName, inferredType }, "Migrated legacy Classic channel to inferred adapter");
250
+ }
251
+ if (isLegacy && inferredType && !channel.adapterId) {
252
+ this.logger.warn({ channelId: channel.channelId, inferredType, instanceName: channel.instanceName }, "Classic legacy channel has no configured adapter for its platform; leaving it unbound");
253
+ }
254
+ if (isCrossPlatformMisbind) {
255
+ const correctlyBound = loaded.filter(candidate => candidate !== entry
256
+ && candidate.channel.channelId === channel.channelId
257
+ && candidate.persistedAdapterId !== undefined
258
+ && this.adapterType(candidate.persistedAdapterId) === inferredType);
259
+ if (correctlyBound.length > 0) {
260
+ // A post-migration /start already created the right registration.
261
+ // Keep the live/correct target, drop only the phantom registry row;
262
+ // its instance/workspace remains on disk for manual recovery.
263
+ repaired = true;
264
+ this.logger.warn({
265
+ channelId: channel.channelId,
266
+ staleAdapterId: persistedAdapterId,
267
+ staleInstanceName: channel.instanceName,
268
+ activeAdapters: correctlyBound.map(candidate => candidate.persistedAdapterId),
269
+ activeInstances: correctlyBound.map(candidate => candidate.channel.instanceName),
270
+ }, "Removed phantom Classic channel registration; instance data retained on disk");
271
+ continue;
272
+ }
273
+ channel.adapterId = inferredAdapterId;
274
+ repaired = true;
275
+ this.logger.warn({
276
+ channelId: channel.channelId,
277
+ fromAdapterId: persistedAdapterId,
278
+ toAdapterId: inferredAdapterId,
279
+ instanceName: channel.instanceName,
280
+ }, "Repaired cross-platform Classic channel adapter binding");
281
+ }
282
+ const mapKey = this.compositeKey(channel.channelId, channel.adapterId);
283
+ const existing = this.channels.get(mapKey);
284
+ if (existing) {
285
+ // Malformed/hand-edited duplicates: prefer the already normalized
286
+ // explicit row and keep all instance data on disk.
287
+ repaired = true;
288
+ this.logger.warn({
289
+ channelId: channel.channelId,
290
+ adapterId: channel.adapterId,
291
+ keptInstanceName: existing.instanceName,
292
+ ignoredInstanceName: channel.instanceName,
293
+ }, "Ignored duplicate Classic channel registration; instance data retained on disk");
294
+ continue;
295
+ }
296
+ this.channels.set(mapKey, channel);
297
+ }
142
298
  }
143
299
  this.rebuildChannelIds();
144
300
  this.lastMtime = statSync(this.configPath).mtimeMs;
145
301
  this.logger.info({ count: this.channels.size }, "Loaded classic channels");
302
+ return repaired;
146
303
  }
147
304
  catch (err) {
148
305
  this.logger.warn({ err }, "Failed to load classicBot.yaml");
306
+ return false;
149
307
  }
150
308
  }
151
309
  save() {
@@ -165,6 +323,10 @@ export class ClassicChannelManager {
165
323
  entry.backend = ch.backend;
166
324
  if (ch.model)
167
325
  entry.model = ch.model;
326
+ if (ch.displayName)
327
+ entry.display_name = ch.displayName;
328
+ if (ch.description)
329
+ entry.description = ch.description;
168
330
  if (ch.autoPauseAfter !== undefined)
169
331
  entry.auto_pause_after = ch.autoPauseAfter;
170
332
  if (ch.contextLines)
@@ -186,12 +348,16 @@ export class ClassicChannelManager {
186
348
  if (mtime <= this.lastMtime)
187
349
  return false;
188
350
  this.logger.info("classicBot.yaml changed — reloading");
189
- this.load();
351
+ const repaired = this.load();
352
+ if (repaired && this.backupBeforeAdapterRepair())
353
+ this.save();
190
354
  return true;
191
355
  }
192
356
  /** Reload immediately after an authenticated settings write. */
193
357
  reloadFromDisk() {
194
- this.load();
358
+ const repaired = this.load();
359
+ if (repaired && this.backupBeforeAdapterRepair())
360
+ this.save();
195
361
  }
196
362
  getDefaults() { return this.defaults; }
197
363
  /** Check if a guild is allowed. Empty/unset/non-array allowed_guilds = allow all (backward compat). */
@@ -199,27 +365,152 @@ export class ClassicChannelManager {
199
365
  const list = this.defaults.allowed_guilds;
200
366
  if (!Array.isArray(list) || list.length === 0)
201
367
  return true;
202
- return list.includes(guildId);
368
+ // String comparison, matching isAdmin. A hand-edited config can hold an
369
+ // UNQUOTED id, which YAML parses as a number; a strict includes() then
370
+ // never matches the string an adapter supplies, and the chat is locked out
371
+ // with no error anywhere. Note this recovers ids below 2^53 only — a
372
+ // Discord snowflake was already truncated at parse time and no comparison
373
+ // can restore it, which is why load() reports those instead (see
374
+ // normalizeId).
375
+ return list.some(entry => String(entry) === String(guildId));
203
376
  }
204
377
  /** Check if a Telegram group is allowed. Empty/unset/non-array = allow all. */
205
378
  isGroupAllowed(groupId) {
206
379
  const list = this.defaults.allowed_groups;
207
380
  if (!Array.isArray(list) || list.length === 0)
208
381
  return true;
209
- return list.includes(groupId);
382
+ // String comparison, matching isAdmin. A hand-edited config can hold an
383
+ // UNQUOTED id, which YAML parses as a number; a strict includes() then
384
+ // never matches the string an adapter supplies, and the chat is locked out
385
+ // with no error anywhere. Note this recovers ids below 2^53 only — a
386
+ // Discord snowflake was already truncated at parse time and no comparison
387
+ // can restore it, which is why load() reports those instead (see
388
+ // normalizeId).
389
+ return list.some(entry => String(entry) === String(groupId));
210
390
  }
211
391
  /** Check if a Telegram user (private chat) is allowed. Empty/unset/non-array = allow all. */
212
392
  isUserAllowed(userId) {
213
393
  const list = this.defaults.allowed_users;
214
394
  if (!Array.isArray(list) || list.length === 0)
215
395
  return true;
216
- return list.includes(userId);
396
+ // String comparison, matching isAdmin. A hand-edited config can hold an
397
+ // UNQUOTED id, which YAML parses as a number; a strict includes() then
398
+ // never matches the string an adapter supplies, and the chat is locked out
399
+ // with no error anywhere. Note this recovers ids below 2^53 only — a
400
+ // Discord snowflake was already truncated at parse time and no comparison
401
+ // can restore it, which is why load() reports those instead (see
402
+ // normalizeId).
403
+ return list.some(entry => String(entry) === String(userId));
217
404
  }
218
405
  /** Check if a user is admin. Empty/unset admin_users = no admins (secure default). */
219
406
  isAdmin(userId) {
220
407
  const list = this.defaults.admin_users;
221
408
  return !!list && list.length > 0 && list.some(id => String(id) === String(userId));
222
409
  }
410
+ /**
411
+ * Grant access to a Discord guild / Telegram group, or promote a user to
412
+ * ClassicBot admin, and persist. Returns what happened so the caller can say
413
+ * so rather than claiming a change that did not occur.
414
+ *
415
+ * Ids are stored with String(): a Discord snowflake exceeds 2^53 and silently
416
+ * loses precision as a YAML integer, after which the strict `includes()` in
417
+ * the isAllowed checks stops matching it.
418
+ *
419
+ * "already-open" is not a no-op for tidiness — it is a guard. An empty
420
+ * allowed_guilds/allowed_groups means allow-all, so writing the FIRST entry
421
+ * would flip the fleet to an allow-list and lock out every other guild that
422
+ * works today. The caller asked to allow this one, not to restrict the rest.
423
+ */
424
+ grantTo(field, id) {
425
+ const value = String(id);
426
+ const list = this.defaults[field];
427
+ // admin_users has no allow-all semantics: empty means nobody is admin.
428
+ if (field !== "admin_users" && (!Array.isArray(list) || list.length === 0))
429
+ return "already-open";
430
+ // A truncated entry can never equal the real id, so this comparison will
431
+ // not treat it as a duplicate: the correct quoted id is ADDED ALONGSIDE the
432
+ // broken one, which stays until a human removes it. That is the right
433
+ // outcome — access starts working immediately, and nothing silently
434
+ // discards a line the operator wrote.
435
+ if (Array.isArray(list) && list.some(x => String(x) === value))
436
+ return "already";
437
+ // Cast: a preserved out-of-range number stays a number on purpose (see
438
+ // normalizeId). The isAllowed checks compare with String() either way, so
439
+ // the runtime contract holds; only the declared type is narrower than what
440
+ // a hand-edited file can contain.
441
+ this.defaults[field] = [
442
+ ...(Array.isArray(list) ? list.map(v => this.normalizeId(field, v)) : []),
443
+ value,
444
+ ];
445
+ this.save();
446
+ return "added";
447
+ }
448
+ /**
449
+ * Coerce an existing list entry to the string form the isAllowed checks
450
+ * compare against — but only when that is lossless.
451
+ *
452
+ * A YAML number below 2^53 (every Telegram group id) round-trips exactly, so
453
+ * String() genuinely repairs it. A Discord snowflake does NOT: YAML already
454
+ * truncated it at parse time (1496407196106494055 arrives as ...494000), so
455
+ * String() would write a plausible-looking WRONG id back to disk and erase
456
+ * the one clue that something is broken — that the entry is a number rather
457
+ * than a quoted string. Leave those untouched and say so; nothing but the
458
+ * original text can recover the id.
459
+ */
460
+ normalizeId(field, value) {
461
+ if (typeof value !== "number")
462
+ return value;
463
+ if (Number.isSafeInteger(value))
464
+ return String(value);
465
+ // Preserve silently: reportUnquotedIds() already names this entry once at
466
+ // load. Logging here too would emit an error on every subsequent write for
467
+ // a condition the operator has already been told about and cannot fix from
468
+ // this side.
469
+ //
470
+ // Preserving it does NOT keep the original digits: save() re-dumps the
471
+ // truncated number, so after the first write the file itself shows the
472
+ // wrong id. Keeping it as a number is still worth doing — that is the only
473
+ // signal reportUnquotedIds has to find it by — but the file cannot be the
474
+ // source of the correct value, which is why the error says so.
475
+ return value;
476
+ }
477
+ /**
478
+ * Report ids YAML has already truncated, at load rather than on first write.
479
+ *
480
+ * String comparison in the isAllowed checks recovers an unquoted id below
481
+ * 2^53, so those need no warning. A Discord snowflake is different: YAML
482
+ * parsed it into a number that lost its last digits before this code ran, so
483
+ * no comparison can match it and nothing in the stored value can recover it.
484
+ * Without this the symptom is a guild that simply never gets in, with no
485
+ * error anywhere — which is the failure this change exists to remove.
486
+ */
487
+ reportUnquotedIds() {
488
+ for (const field of ["allowed_guilds", "allowed_groups", "allowed_users", "admin_users"]) {
489
+ const list = this.defaults[field];
490
+ if (!Array.isArray(list))
491
+ continue;
492
+ for (const entry of list) {
493
+ if (typeof entry === "number" && !Number.isSafeInteger(entry)) {
494
+ this.logger.error({ field, value: entry, path: this.configPath }, "classicBot.yaml holds an unquoted id too large for YAML — its precision was lost when the "
495
+ + "file was parsed, so it can never match. Re-enter it as a QUOTED string taken from Discord "
496
+ + "or Telegram: the digits currently in the file are themselves already wrong, because any "
497
+ + "save rewrites this entry from the truncated value. Do not copy it from the file.");
498
+ }
499
+ }
500
+ }
501
+ }
502
+ /** Allow a Discord guild to use ClassicBot. */
503
+ allowGuild(guildId) {
504
+ return this.grantTo("allowed_guilds", guildId);
505
+ }
506
+ /** Allow a Telegram group to use ClassicBot. */
507
+ allowGroup(groupId) {
508
+ return this.grantTo("allowed_groups", groupId);
509
+ }
510
+ /** Promote a user to ClassicBot admin (start/stop/model on classic channels). */
511
+ addAdminUser(userId) {
512
+ return this.grantTo("admin_users", userId);
513
+ }
223
514
  /** Set the model override for the channel owning `instanceName` and persist. Returns true if found. */
224
515
  setModelByInstance(instanceName, model) {
225
516
  for (const ch of this.channels.values()) {
@@ -231,6 +522,28 @@ export class ClassicChannelManager {
231
522
  }
232
523
  return false;
233
524
  }
525
+ /** Persist agent-selected identity for a Classic instance. */
526
+ setDisplayNameByInstance(instanceName, displayName) {
527
+ for (const ch of this.channels.values()) {
528
+ if (ch.instanceName === instanceName) {
529
+ ch.displayName = displayName;
530
+ this.save();
531
+ return true;
532
+ }
533
+ }
534
+ return false;
535
+ }
536
+ /** Persist agent-selected role/description for a Classic instance. */
537
+ setDescriptionByInstance(instanceName, description) {
538
+ for (const ch of this.channels.values()) {
539
+ if (ch.instanceName === instanceName) {
540
+ ch.description = description;
541
+ this.save();
542
+ return true;
543
+ }
544
+ }
545
+ return false;
546
+ }
234
547
  /** Toggle collab mode for a channel. Returns new state. */
235
548
  toggleCollab(channelId, adapterId) {
236
549
  const ch = this.find(channelId, adapterId);