@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.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +12 -8
- package/dist/backend/antigravity.js +189 -42
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +62 -23
- package/dist/backend/claude-code.js +296 -43
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +148 -8
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.js +2 -2
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.js +2 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +52 -2
- package/dist/backend/kiro.js +267 -12
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +27 -11
- package/dist/backend/opencode.js +65 -41
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +97 -3
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-outage.d.ts +61 -0
- package/dist/backend-outage.js +71 -0
- package/dist/backend-outage.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +44 -2
- package/dist/channel/adapters/discord.js +526 -78
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +30 -0
- package/dist/channel/adapters/telegram.js +221 -19
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/agy-mcp-launcher.d.ts +2 -0
- package/dist/channel/agy-mcp-launcher.js +28 -0
- package/dist/channel/agy-mcp-launcher.js.map +1 -0
- package/dist/channel/ipc-bridge.d.ts +2 -1
- package/dist/channel/ipc-bridge.js +33 -4
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-server.js +28 -25
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js +62 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/types.d.ts +31 -1
- package/dist/classic-channel-manager.d.ts +85 -3
- package/dist/classic-channel-manager.js +347 -34
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +109 -37
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +48 -7
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +6 -0
- package/dist/cross-instance-envelope.js +30 -0
- package/dist/cross-instance-envelope.js.map +1 -0
- package/dist/daemon.d.ts +429 -9
- package/dist/daemon.js +1843 -289
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +41 -0
- package/dist/doctor.js +267 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fleet-context.d.ts +54 -0
- package/dist/fleet-manager.d.ts +398 -11
- package/dist/fleet-manager.js +2756 -499
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-yaml-slim.d.ts +10 -0
- package/dist/fleet-yaml-slim.js +59 -0
- package/dist/fleet-yaml-slim.js.map +1 -0
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
- package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
- package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
- package/dist/general-knowledge/skills/session-management/SKILL.md +172 -8
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
- package/dist/instance-lifecycle.d.ts +99 -0
- package/dist/instance-lifecycle.js +339 -11
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +35 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +1004 -288
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +91 -0
- package/dist/login-flows.js +170 -0
- package/dist/login-flows.js.map +1 -0
- package/dist/login-manager.d.ts +63 -0
- package/dist/login-manager.js +134 -0
- package/dist/login-manager.js.map +1 -0
- package/dist/network-family.d.ts +18 -0
- package/dist/network-family.js +20 -0
- package/dist/network-family.js.map +1 -0
- package/dist/outbound-handlers.d.ts +8 -0
- package/dist/outbound-handlers.js +101 -18
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -1
- package/dist/outbound-schemas.js +9 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +52 -0
- package/dist/pane-input-residue.js +107 -0
- package/dist/pane-input-residue.js.map +1 -0
- package/dist/reply-dedup.d.ts +7 -8
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-progress.d.ts +2 -0
- package/dist/restart-progress.js +3 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +12 -0
- package/dist/scheduler/db.js +59 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-installer.d.ts +11 -0
- package/dist/service-installer.js +84 -18
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +30 -0
- package/dist/spawn-gate.js +79 -0
- package/dist/spawn-gate.js.map +1 -0
- package/dist/storm-window.d.ts +83 -0
- package/dist/storm-window.js +251 -0
- package/dist/storm-window.js.map +1 -0
- package/dist/tips.d.ts +44 -0
- package/dist/tips.js +355 -0
- package/dist/tips.js.map +1 -0
- package/dist/tmux-manager.d.ts +16 -0
- package/dist/tmux-manager.js +110 -25
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-progress.d.ts +40 -0
- package/dist/tool-progress.js +289 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/topic-commands.d.ts +42 -4
- package/dist/topic-commands.js +372 -67
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.d.ts +15 -2
- package/dist/transcript-monitor.js +63 -17
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +131 -0
- package/dist/transcript-sources.js +580 -0
- package/dist/transcript-sources.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +200 -60
- package/dist/ui/view.html +147 -30
- package/dist/usage/format-rich.d.ts +1 -1
- package/dist/usage/format-rich.js +28 -24
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +7 -0
- package/dist/usage/i18n-keys.js +34 -0
- package/dist/usage/i18n-keys.js.map +1 -0
- package/dist/usage/i18n.d.ts +5 -0
- package/dist/usage/i18n.js +27 -0
- package/dist/usage/i18n.js.map +1 -0
- package/dist/usage/providers.d.ts +21 -0
- package/dist/usage/providers.js +153 -75
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +11 -3
- package/dist/usage/usage-api.js +61 -24
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/workflow-templates/default.md +2 -1
- 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.
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
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 =
|
|
82
|
-
|
|
83
|
-
this.load();
|
|
84
|
-
if (
|
|
85
|
-
this.save();
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|