clementine-agent 1.0.78 → 1.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +63 -14
- package/dist/gateway/agent-heartbeat-manager.d.ts +3 -2
- package/dist/gateway/agent-heartbeat-manager.js +4 -2
- package/dist/gateway/agent-heartbeat-scheduler.d.ts +32 -2
- package/dist/gateway/agent-heartbeat-scheduler.js +79 -9
- package/dist/index.js +3 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1074,10 +1074,16 @@ function cmdTools() {
|
|
|
1074
1074
|
}
|
|
1075
1075
|
// ── Program ──────────────────────────────────────────────────────────
|
|
1076
1076
|
const program = new Command();
|
|
1077
|
+
let pkgVersion = '0.0.0';
|
|
1078
|
+
try {
|
|
1079
|
+
const pkgRaw = readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8');
|
|
1080
|
+
pkgVersion = String(JSON.parse(pkgRaw).version ?? '0.0.0');
|
|
1081
|
+
}
|
|
1082
|
+
catch { /* fall back to placeholder */ }
|
|
1077
1083
|
program
|
|
1078
1084
|
.name('clementine')
|
|
1079
1085
|
.description('Clementine Personal AI Assistant')
|
|
1080
|
-
.version(
|
|
1086
|
+
.version(pkgVersion);
|
|
1081
1087
|
program
|
|
1082
1088
|
.command('launch')
|
|
1083
1089
|
.description('Start the assistant (daemon by default)')
|
|
@@ -1609,11 +1615,46 @@ async function cmdUpdate(options) {
|
|
|
1609
1615
|
console.log();
|
|
1610
1616
|
console.log(` ${DIM}Updating ${getAssistantName()}...${RESET}`);
|
|
1611
1617
|
console.log();
|
|
1612
|
-
// 1.
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1618
|
+
// 1. Detect install flavor. Two valid paths:
|
|
1619
|
+
// - git-clone install (PACKAGE_ROOT has .git) → pull + rebuild path below
|
|
1620
|
+
// - npm-global install (no .git) → delegate to `npm install -g clementine-agent@latest`
|
|
1621
|
+
const isGitInstall = existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
1622
|
+
if (!isGitInstall) {
|
|
1623
|
+
if (options.dryRun) {
|
|
1624
|
+
console.log(` ${DIM}[dry-run]${RESET} Would run: npm install -g clementine-agent@latest`);
|
|
1625
|
+
if (options.restart)
|
|
1626
|
+
console.log(` ${DIM}[dry-run]${RESET} Would restart the daemon`);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
console.log(` ${DIM}npm-global install detected at ${PACKAGE_ROOT}${RESET}`);
|
|
1630
|
+
console.log(` ${DIM}Running: npm install -g clementine-agent@latest${RESET}`);
|
|
1631
|
+
console.log();
|
|
1632
|
+
try {
|
|
1633
|
+
execSync('npm install -g clementine-agent@latest', { stdio: 'inherit' });
|
|
1634
|
+
console.log();
|
|
1635
|
+
console.log(` ${GREEN}OK${RESET} Updated via npm`);
|
|
1636
|
+
}
|
|
1637
|
+
catch (err) {
|
|
1638
|
+
console.error(` ${RED}FAIL${RESET} npm update failed: ${String(err).slice(0, 200)}`);
|
|
1639
|
+
console.error(` ${YELLOW}Hint${RESET} If you see EACCES, see README "Troubleshooting" for npm prefix setup.`);
|
|
1640
|
+
process.exit(1);
|
|
1641
|
+
}
|
|
1642
|
+
if (options.restart) {
|
|
1643
|
+
try {
|
|
1644
|
+
console.log(` ${DIM}Restarting daemon...${RESET}`);
|
|
1645
|
+
execSync('clementine restart', { stdio: 'inherit' });
|
|
1646
|
+
console.log(` ${GREEN}OK${RESET} Daemon restarted`);
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
console.error(` ${YELLOW}WARN${RESET} Restart failed: ${String(err).slice(0, 200)}. Run \`clementine restart\` manually.`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
console.log();
|
|
1654
|
+
console.log(` ${DIM}Restart your daemon to pick up the new code:${RESET}`);
|
|
1655
|
+
console.log(` clementine restart`);
|
|
1656
|
+
}
|
|
1657
|
+
return;
|
|
1617
1658
|
}
|
|
1618
1659
|
let step = 0;
|
|
1619
1660
|
const S = () => `[${++step}]`;
|
|
@@ -1643,11 +1684,16 @@ async function cmdUpdate(options) {
|
|
|
1643
1684
|
try {
|
|
1644
1685
|
const status = execSync('git status --porcelain', { cwd: PACKAGE_ROOT, encoding: 'utf-8' }).trim();
|
|
1645
1686
|
if (status) {
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
console.log(` ${
|
|
1687
|
+
if (options.dryRun) {
|
|
1688
|
+
console.log(` ${S()} Would stash local changes`);
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
console.log(` ${S()} Stashing local changes...`);
|
|
1692
|
+
const stashOut = execSync('git stash', { cwd: PACKAGE_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
1693
|
+
didStash = !stashOut.includes('No local changes');
|
|
1694
|
+
if (didStash) {
|
|
1695
|
+
console.log(` ${GREEN}OK${RESET} Stashed local changes`);
|
|
1696
|
+
}
|
|
1651
1697
|
}
|
|
1652
1698
|
}
|
|
1653
1699
|
}
|
|
@@ -1688,15 +1734,18 @@ async function cmdUpdate(options) {
|
|
|
1688
1734
|
const pid = readPid();
|
|
1689
1735
|
const wasRunning = pid && isProcessAlive(pid);
|
|
1690
1736
|
if (wasRunning) {
|
|
1691
|
-
|
|
1692
|
-
|
|
1737
|
+
if (options.dryRun) {
|
|
1738
|
+
console.log(` ${S()} Would stop daemon (PID ${pid})`);
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
console.log(` ${S()} Stopping daemon (PID ${pid})...`);
|
|
1693
1742
|
stopDaemon(pid);
|
|
1694
1743
|
try {
|
|
1695
1744
|
unlinkSync(getPidFilePath());
|
|
1696
1745
|
}
|
|
1697
1746
|
catch { /* ignore */ }
|
|
1747
|
+
console.log(` ${GREEN}OK${RESET} Daemon stopped`);
|
|
1698
1748
|
}
|
|
1699
|
-
console.log(` ${GREEN}OK${RESET} Daemon stopped`);
|
|
1700
1749
|
}
|
|
1701
1750
|
// Helper: if update fails after stopping daemon, relaunch before exiting
|
|
1702
1751
|
function failAndRestart(backupDir) {
|
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
* daemon or stall others.
|
|
12
12
|
*/
|
|
13
13
|
import type { AgentManager } from '../agent/agent-manager.js';
|
|
14
|
-
import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
|
|
14
|
+
import { AgentHeartbeatScheduler, type AgentHeartbeatGateway } from './agent-heartbeat-scheduler.js';
|
|
15
15
|
export declare class AgentHeartbeatManager {
|
|
16
16
|
private readonly agentManager;
|
|
17
|
+
private readonly gateway;
|
|
17
18
|
private readonly schedulers;
|
|
18
19
|
private timer;
|
|
19
20
|
private running;
|
|
20
21
|
private ticking;
|
|
21
|
-
constructor(agentManager: AgentManager);
|
|
22
|
+
constructor(agentManager: AgentManager, gateway?: AgentHeartbeatGateway);
|
|
22
23
|
start(): void;
|
|
23
24
|
stop(): void;
|
|
24
25
|
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
@@ -16,12 +16,14 @@ const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
|
|
|
16
16
|
const OUTER_TICK_MS = 60_000;
|
|
17
17
|
export class AgentHeartbeatManager {
|
|
18
18
|
agentManager;
|
|
19
|
+
gateway;
|
|
19
20
|
schedulers = new Map();
|
|
20
21
|
timer = null;
|
|
21
22
|
running = false;
|
|
22
23
|
ticking = false;
|
|
23
|
-
constructor(agentManager) {
|
|
24
|
+
constructor(agentManager, gateway) {
|
|
24
25
|
this.agentManager = agentManager;
|
|
26
|
+
this.gateway = gateway ?? null;
|
|
25
27
|
}
|
|
26
28
|
start() {
|
|
27
29
|
if (this.running)
|
|
@@ -63,7 +65,7 @@ export class AgentHeartbeatManager {
|
|
|
63
65
|
// Add new
|
|
64
66
|
for (const slug of active) {
|
|
65
67
|
if (!this.schedulers.has(slug)) {
|
|
66
|
-
this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager));
|
|
68
|
+
this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager, this.gateway ? { gateway: this.gateway } : {}));
|
|
67
69
|
logger.info({ slug }, 'Agent heartbeat: registered scheduler');
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -12,11 +12,24 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { AgentHeartbeatState } from '../types.js';
|
|
14
14
|
import type { AgentManager } from '../agent/agent-manager.js';
|
|
15
|
+
/**
|
|
16
|
+
* Minimal gateway surface the scheduler needs for the LLM tick path.
|
|
17
|
+
* Kept narrow so tests can mock it without pulling in the full Gateway.
|
|
18
|
+
*/
|
|
19
|
+
export interface AgentHeartbeatGateway {
|
|
20
|
+
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
|
21
|
+
}
|
|
15
22
|
export interface AgentHeartbeatOptions {
|
|
16
23
|
/** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
|
|
17
24
|
baseDir?: string;
|
|
18
25
|
/** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
|
|
19
26
|
agentsDir?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Gateway used for the LLM tick path. When omitted, the scheduler runs in
|
|
29
|
+
* cheap-path-only mode (observation + logging, no LLM call). Tests pass
|
|
30
|
+
* mocks here; production passes the real Gateway.
|
|
31
|
+
*/
|
|
32
|
+
gateway?: AgentHeartbeatGateway;
|
|
20
33
|
}
|
|
21
34
|
export declare class AgentHeartbeatScheduler {
|
|
22
35
|
private readonly slug;
|
|
@@ -24,6 +37,7 @@ export declare class AgentHeartbeatScheduler {
|
|
|
24
37
|
private readonly baseDir;
|
|
25
38
|
private readonly agentsDir;
|
|
26
39
|
private readonly stateFile;
|
|
40
|
+
private readonly gateway;
|
|
27
41
|
constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
|
|
28
42
|
/** Read persisted state, or return a fresh state ready to tick now. */
|
|
29
43
|
loadState(): AgentHeartbeatState;
|
|
@@ -37,10 +51,26 @@ export declare class AgentHeartbeatScheduler {
|
|
|
37
51
|
*/
|
|
38
52
|
private buildFingerprint;
|
|
39
53
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
54
|
+
* Tick. Loads state, builds fingerprint, decides whether to invoke the
|
|
55
|
+
* LLM path, persists the new state. The LLM call is only made when:
|
|
56
|
+
*
|
|
57
|
+
* 1. The fingerprint changed (something material moved since last tick), AND
|
|
58
|
+
* 2. The prior fingerprint was non-empty (we don't fire LLM on the very
|
|
59
|
+
* first tick after daemon start — those are noisy and not signal), AND
|
|
60
|
+
* 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
|
|
42
61
|
*/
|
|
43
62
|
tick(now?: Date): Promise<AgentHeartbeatState>;
|
|
63
|
+
/**
|
|
64
|
+
* Build and dispatch the LLM tick prompt via gateway.handleCronJob.
|
|
65
|
+
* Output already routes to the agent's Discord channel (dispatcher.send
|
|
66
|
+
* is called inside the cron path with the agentSlug).
|
|
67
|
+
*/
|
|
68
|
+
private runLlmTick;
|
|
69
|
+
/** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
|
|
70
|
+
static parseLlmTickOutput(output: string): {
|
|
71
|
+
nextCheckMinutes: number | undefined;
|
|
72
|
+
summary: string;
|
|
73
|
+
};
|
|
44
74
|
/** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
|
|
45
75
|
setNextCheckIn(minutes: number, now?: Date): void;
|
|
46
76
|
getSlug(): string;
|
|
@@ -26,12 +26,14 @@ export class AgentHeartbeatScheduler {
|
|
|
26
26
|
baseDir;
|
|
27
27
|
agentsDir;
|
|
28
28
|
stateFile;
|
|
29
|
+
gateway;
|
|
29
30
|
constructor(slug, agentManager, opts = {}) {
|
|
30
31
|
this.slug = slug;
|
|
31
32
|
this.agentManager = agentManager;
|
|
32
33
|
this.baseDir = opts.baseDir ?? BASE_DIR;
|
|
33
34
|
this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
|
|
34
35
|
this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
|
|
36
|
+
this.gateway = opts.gateway ?? null;
|
|
35
37
|
}
|
|
36
38
|
/** Read persisted state, or return a fresh state ready to tick now. */
|
|
37
39
|
loadState() {
|
|
@@ -151,8 +153,13 @@ export class AgentHeartbeatScheduler {
|
|
|
151
153
|
return { fingerprint, signals };
|
|
152
154
|
}
|
|
153
155
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
+
* Tick. Loads state, builds fingerprint, decides whether to invoke the
|
|
157
|
+
* LLM path, persists the new state. The LLM call is only made when:
|
|
158
|
+
*
|
|
159
|
+
* 1. The fingerprint changed (something material moved since last tick), AND
|
|
160
|
+
* 2. The prior fingerprint was non-empty (we don't fire LLM on the very
|
|
161
|
+
* first tick after daemon start — those are noisy and not signal), AND
|
|
162
|
+
* 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
|
|
156
163
|
*/
|
|
157
164
|
async tick(now = new Date()) {
|
|
158
165
|
const profile = this.agentManager.get(this.slug);
|
|
@@ -183,28 +190,91 @@ export class AgentHeartbeatScheduler {
|
|
|
183
190
|
const prior = this.loadState();
|
|
184
191
|
const { fingerprint, signals } = this.buildFingerprint();
|
|
185
192
|
const changed = fingerprint !== prior.fingerprint;
|
|
186
|
-
|
|
193
|
+
let nextCheckMinutes = DEFAULT_INTERVAL_MIN;
|
|
194
|
+
let lastSignalSummary;
|
|
195
|
+
const shouldRunLlm = changed && prior.fingerprint !== '' && this.gateway !== null;
|
|
196
|
+
if (shouldRunLlm) {
|
|
197
|
+
try {
|
|
198
|
+
const result = await this.runLlmTick(profile, signals, prior, now);
|
|
199
|
+
nextCheckMinutes = result.nextCheckMinutes ?? DEFAULT_INTERVAL_MIN;
|
|
200
|
+
lastSignalSummary = result.summary?.slice(0, 240);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
logger.warn({ err, slug: this.slug }, 'Agent LLM tick failed — using default cadence');
|
|
204
|
+
lastSignalSummary = `llm tick error: ${String(err).slice(0, 200)}`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (changed) {
|
|
208
|
+
lastSignalSummary = `signal change: ${JSON.stringify(signals)}`.slice(0, 240);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
lastSignalSummary = prior.lastSignalSummary;
|
|
212
|
+
}
|
|
213
|
+
const clampedMin = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(nextCheckMinutes)));
|
|
214
|
+
const next = new Date(now.getTime() + clampedMin * 60_000);
|
|
187
215
|
const state = {
|
|
188
216
|
slug: this.slug,
|
|
189
217
|
lastTickAt: now.toISOString(),
|
|
190
218
|
nextCheckAt: next.toISOString(),
|
|
191
219
|
silentTickCount: changed ? 0 : prior.silentTickCount + 1,
|
|
192
220
|
fingerprint,
|
|
193
|
-
...(
|
|
194
|
-
? { lastSignalSummary: `signal change: ${JSON.stringify(signals)}`.slice(0, 240) }
|
|
195
|
-
: prior.lastSignalSummary
|
|
196
|
-
? { lastSignalSummary: prior.lastSignalSummary }
|
|
197
|
-
: {}),
|
|
221
|
+
...(lastSignalSummary ? { lastSignalSummary } : {}),
|
|
198
222
|
};
|
|
199
223
|
this.saveState(state);
|
|
200
224
|
if (changed) {
|
|
201
|
-
logger.info({ slug: this.slug, signals, fingerprint
|
|
225
|
+
logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, nextCheckMin: clampedMin }, 'Agent heartbeat tick');
|
|
202
226
|
}
|
|
203
227
|
else {
|
|
204
228
|
logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
|
|
205
229
|
}
|
|
206
230
|
return state;
|
|
207
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Build and dispatch the LLM tick prompt via gateway.handleCronJob.
|
|
234
|
+
* Output already routes to the agent's Discord channel (dispatcher.send
|
|
235
|
+
* is called inside the cron path with the agentSlug).
|
|
236
|
+
*/
|
|
237
|
+
async runLlmTick(profile, signals, prior, now) {
|
|
238
|
+
if (!this.gateway) {
|
|
239
|
+
return { nextCheckMinutes: undefined, summary: '' };
|
|
240
|
+
}
|
|
241
|
+
const sinceLastMin = prior.lastTickAt
|
|
242
|
+
? Math.max(0, Math.round((now.getTime() - new Date(prior.lastTickAt).getTime()) / 60_000))
|
|
243
|
+
: 0;
|
|
244
|
+
const prompt = [
|
|
245
|
+
`[Heartbeat check-in: ${profile.slug}]`,
|
|
246
|
+
'',
|
|
247
|
+
`You are ${profile.name}. ${profile.description}`,
|
|
248
|
+
'',
|
|
249
|
+
`## Routine check-in`,
|
|
250
|
+
`This is your scheduled heartbeat tick (${sinceLastMin}min since last).`,
|
|
251
|
+
`Something in your scope has changed since you last checked in.`,
|
|
252
|
+
'',
|
|
253
|
+
`### Signals`,
|
|
254
|
+
`- Pending delegated tasks: ${signals.pendingTasks ?? 0}`,
|
|
255
|
+
`- Latest goal update: ${signals.latestGoalUpdate || 'none'}`,
|
|
256
|
+
`- Latest cron run: ${signals.latestCronRunMs ? new Date(Number(signals.latestCronRunMs)).toISOString() : 'none'}`,
|
|
257
|
+
'',
|
|
258
|
+
`### Instructions`,
|
|
259
|
+
`1. Quickly scan TASKS.md, your goals, and recent cron output for anything that needs action right now.`,
|
|
260
|
+
`2. If there's a clear next action you can take in 1–2 turns, do it.`,
|
|
261
|
+
`3. If you're blocked, waiting on someone, or it's all-quiet, say so concisely.`,
|
|
262
|
+
`4. End your response with \`[NEXT_CHECK: Xm]\` to set when to check in next (5–720 min). Default 30m. Use shorter intervals during active work, longer during quiet hours.`,
|
|
263
|
+
`5. Keep your response under 3 sentences unless you actually took action.`,
|
|
264
|
+
].join('\n');
|
|
265
|
+
const jobName = `heartbeat:${this.slug}`;
|
|
266
|
+
const result = await this.gateway.handleCronJob(jobName, prompt, 1, 5, undefined, undefined, 'standard', undefined, undefined, undefined, this.slug);
|
|
267
|
+
const parsed = AgentHeartbeatScheduler.parseLlmTickOutput(result);
|
|
268
|
+
return { nextCheckMinutes: parsed.nextCheckMinutes, summary: parsed.summary };
|
|
269
|
+
}
|
|
270
|
+
/** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
|
|
271
|
+
static parseLlmTickOutput(output) {
|
|
272
|
+
const match = output.match(/\[NEXT_CHECK:\s*(\d+)\s*m?\]/i);
|
|
273
|
+
const nextCheckMinutes = match ? parseInt(match[1], 10) : undefined;
|
|
274
|
+
// Strip the directive from the summary so logs don't echo it back
|
|
275
|
+
const summary = output.replace(/\[NEXT_CHECK:[^\]]*\]/gi, '').trim();
|
|
276
|
+
return { nextCheckMinutes, summary };
|
|
277
|
+
}
|
|
208
278
|
/** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
|
|
209
279
|
setNextCheckIn(minutes, now = new Date()) {
|
|
210
280
|
const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
|
package/dist/index.js
CHANGED
|
@@ -661,9 +661,10 @@ async function asyncMain() {
|
|
|
661
661
|
const cronScheduler = new CronScheduler(gateway, dispatcher);
|
|
662
662
|
heartbeat.setCronScheduler(cronScheduler);
|
|
663
663
|
// Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
|
|
664
|
-
// observation
|
|
664
|
+
// observation on every tick; LLM tick fires on signal change with the
|
|
665
|
+
// agent's profile and routes output to their Discord channel.
|
|
665
666
|
const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
|
|
666
|
-
const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
|
|
667
|
+
const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
|
|
667
668
|
// ── Build channel tasks ──────────────────────────────────────────
|
|
668
669
|
const channelTasks = [];
|
|
669
670
|
const activeChannels = [];
|