@masslessai/push-todo 4.4.0 → 4.5.1
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/SKILL.md +6 -0
- package/lib/cli.js +9 -1
- package/lib/cron.js +18 -4
- package/lib/daemon.js +1 -87
- package/package.json +2 -1
- package/scripts/postinstall.js +88 -4
- package/skills/skill-builder/SKILL.md +313 -0
- package/skills/skill-builder/agents/analyzer.md +274 -0
- package/skills/skill-builder/agents/comparator.md +202 -0
- package/skills/skill-builder/agents/grader.md +223 -0
- package/skills/skill-builder/references/description-optimization.md +212 -0
- package/skills/skill-builder/references/eval-framework.md +230 -0
- package/skills/skill-builder/references/json-schemas.md +430 -0
- package/skills/skill-builder/references/plugin-skill-patterns.md +122 -0
- package/skills/skill-builder/references/writing-guide.md +330 -0
- package/skills/skill-builder/scripts/__init__.py +0 -0
- package/skills/skill-builder/scripts/aggregate_benchmark.py +401 -0
- package/skills/skill-builder/scripts/generate_report.py +326 -0
- package/skills/skill-builder/scripts/improve_description.py +248 -0
- package/skills/skill-builder/scripts/quick_validate.py +103 -0
- package/skills/skill-builder/scripts/run_eval.py +310 -0
- package/skills/skill-builder/scripts/run_loop.py +332 -0
- package/skills/skill-builder/scripts/utils.py +47 -0
package/SKILL.md
CHANGED
|
@@ -433,6 +433,12 @@ The `push-todo` CLI supports these commands:
|
|
|
433
433
|
| `push-todo --status` | Show connection status |
|
|
434
434
|
| `push-todo --mark-completed <uuid>` | Mark task as completed |
|
|
435
435
|
| `push-todo --json` | Output as JSON |
|
|
436
|
+
| `push-todo create <title>` | Create a todo from CLI |
|
|
437
|
+
| `push-todo create <title> --remind <text>` | Create with reminder (e.g., "tomorrow night", "in 2 hours") |
|
|
438
|
+
| `push-todo create <title> --remind-at <iso>` | Create with exact reminder date (ISO8601) |
|
|
439
|
+
| `push-todo create <title> --alarm` | Mark reminder as urgent (bypasses Focus) |
|
|
440
|
+
| `push-todo create <title> --content <text>` | Create with detailed content |
|
|
441
|
+
| `push-todo create <title> --backlog` | Create as backlog item |
|
|
436
442
|
| `push-todo schedule add` | Create a remote schedule (Supabase-backed) |
|
|
437
443
|
| `push-todo schedule list` | List all remote schedules |
|
|
438
444
|
| `push-todo schedule remove <id>` | Remove a schedule |
|
package/lib/cli.js
CHANGED
|
@@ -132,6 +132,7 @@ ${bold('SCHEDULE (remote scheduled jobs):')}
|
|
|
132
132
|
--create-todo <title> Create a new todo each fire
|
|
133
133
|
--queue-todo <todoId> Re-queue an existing todo each fire
|
|
134
134
|
--git-remote <remote> Route created todos to a project
|
|
135
|
+
--agent-type <type> Agent: claude-code, openclaw, openai-codex
|
|
135
136
|
--content <body> Expanded content for created todos
|
|
136
137
|
push-todo schedule list List all schedules
|
|
137
138
|
push-todo schedule remove <id> Remove a schedule
|
|
@@ -201,6 +202,7 @@ const options = {
|
|
|
201
202
|
'cron': { type: 'string' },
|
|
202
203
|
'create-todo': { type: 'string' },
|
|
203
204
|
'git-remote': { type: 'string' },
|
|
205
|
+
'agent-type': { type: 'string' },
|
|
204
206
|
'queue-todo': { type: 'string' },
|
|
205
207
|
// Skill CLI options (Phase 3)
|
|
206
208
|
'report-progress': { type: 'string' },
|
|
@@ -825,6 +827,9 @@ export async function run(argv) {
|
|
|
825
827
|
process.exit(1);
|
|
826
828
|
}
|
|
827
829
|
|
|
830
|
+
// Resolve agent type: explicit flag > auto-detect from caller
|
|
831
|
+
const agentType = values['agent-type'] || null;
|
|
832
|
+
|
|
828
833
|
try {
|
|
829
834
|
const response = await api.apiRequest('manage-schedules', {
|
|
830
835
|
method: 'POST',
|
|
@@ -833,6 +838,7 @@ export async function run(argv) {
|
|
|
833
838
|
scheduleType,
|
|
834
839
|
scheduleValue,
|
|
835
840
|
actionType,
|
|
841
|
+
agentType,
|
|
836
842
|
actionTitle,
|
|
837
843
|
actionContent,
|
|
838
844
|
gitRemote,
|
|
@@ -884,7 +890,8 @@ export async function run(argv) {
|
|
|
884
890
|
const actionStr = s.action_type === 'create-todo'
|
|
885
891
|
? `create-todo: ${s.action_title || ''}`
|
|
886
892
|
: `queue-todo: ${s.todo_id || '?'}`;
|
|
887
|
-
|
|
893
|
+
const agentStr = s.agent_type && s.agent_type !== 'claude-code' ? ` (${s.agent_type})` : '';
|
|
894
|
+
console.log(` ${status} ${s.name} [${schedStr}] → ${actionStr}${agentStr}`);
|
|
888
895
|
console.log(dim(` ID: ${s.id.slice(0, 8)} | Next: ${s.next_run_at || 'N/A'} | Last: ${s.last_run_at || 'never'}`));
|
|
889
896
|
}
|
|
890
897
|
} catch (error) {
|
|
@@ -958,6 +965,7 @@ ${bold('Examples:')}
|
|
|
958
965
|
push-todo schedule add --name "Daily standup" --every 24h --create-todo "Write standup update"
|
|
959
966
|
push-todo schedule add --name "Weekly review" --cron "0 9 * * 1" --create-todo "Weekly code review" --git-remote github.com/user/repo
|
|
960
967
|
push-todo schedule add --name "Re-run task" --every 4h --queue-todo <todoId>
|
|
968
|
+
push-todo schedule add --name "Codex review" --every 12h --create-todo "Review PRs" --git-remote github.com/user/repo --agent-type openai-codex
|
|
961
969
|
`);
|
|
962
970
|
return;
|
|
963
971
|
}
|
package/lib/cron.js
CHANGED
|
@@ -196,16 +196,21 @@ export function computeNextRun(schedule, fromDate = new Date()) {
|
|
|
196
196
|
* @param {Function} [logFn] - Optional log function
|
|
197
197
|
* @param {Object} [context] - Injected dependencies from daemon
|
|
198
198
|
* @param {Function} [context.apiRequest] - API request function
|
|
199
|
+
* @param {string} [context.machineId] - This machine's ID for targeted schedule filtering
|
|
199
200
|
*/
|
|
200
201
|
export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
201
202
|
const log = logFn || (() => {});
|
|
202
203
|
|
|
203
204
|
if (!context.apiRequest) return;
|
|
204
205
|
|
|
205
|
-
// 1. Fetch due schedules
|
|
206
|
+
// 1. Fetch due schedules (filtered by machine if available)
|
|
206
207
|
let schedules;
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
+
let endpoint = 'manage-schedules?due=true';
|
|
210
|
+
if (context.machineId) {
|
|
211
|
+
endpoint += `&machine_id=${encodeURIComponent(context.machineId)}`;
|
|
212
|
+
}
|
|
213
|
+
const response = await context.apiRequest(endpoint, {
|
|
209
214
|
method: 'GET',
|
|
210
215
|
});
|
|
211
216
|
if (!response.ok) {
|
|
@@ -226,6 +231,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
226
231
|
// 2. Fire each due schedule
|
|
227
232
|
for (const schedule of schedules) {
|
|
228
233
|
const expectedNextRunAt = schedule.next_run_at;
|
|
234
|
+
let actionSucceeded = false;
|
|
229
235
|
|
|
230
236
|
try {
|
|
231
237
|
if (schedule.action_type === 'create-todo') {
|
|
@@ -238,7 +244,8 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
238
244
|
};
|
|
239
245
|
if (schedule.git_remote) {
|
|
240
246
|
payload.gitRemote = schedule.git_remote;
|
|
241
|
-
|
|
247
|
+
// Use schedule's agent_type instead of hardcoding claude-code
|
|
248
|
+
payload.actionType = schedule.agent_type || 'claude-code';
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
const todoResponse = await context.apiRequest('create-todo', {
|
|
@@ -248,6 +255,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
248
255
|
if (todoResponse.ok) {
|
|
249
256
|
const todoData = await todoResponse.json();
|
|
250
257
|
log(`Schedule "${schedule.name}": created todo #${todoData.todo?.displayNumber || '?'}`);
|
|
258
|
+
actionSucceeded = true;
|
|
251
259
|
} else {
|
|
252
260
|
log(`Schedule "${schedule.name}": create-todo failed (HTTP ${todoResponse.status})`);
|
|
253
261
|
}
|
|
@@ -277,6 +285,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
277
285
|
});
|
|
278
286
|
if (queueResponse.ok) {
|
|
279
287
|
log(`Schedule "${schedule.name}": queued todo ${schedule.todo_id}`);
|
|
288
|
+
actionSucceeded = true;
|
|
280
289
|
} else {
|
|
281
290
|
log(`Schedule "${schedule.name}": queue-todo failed (HTTP ${queueResponse.status})`);
|
|
282
291
|
}
|
|
@@ -285,7 +294,12 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
285
294
|
log(`Schedule "${schedule.name}": execution error: ${error.message}`);
|
|
286
295
|
}
|
|
287
296
|
|
|
288
|
-
// 3. Advance next_run_at (
|
|
297
|
+
// 3. Advance next_run_at only if action succeeded (prevents silent lost fires)
|
|
298
|
+
if (!actionSucceeded) {
|
|
299
|
+
log(`Schedule "${schedule.name}": action failed, will retry next cycle`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
289
303
|
const now = new Date();
|
|
290
304
|
const scheduleConfig = { type: schedule.schedule_type, value: schedule.schedule_value };
|
|
291
305
|
|
package/lib/daemon.js
CHANGED
|
@@ -550,85 +550,6 @@ async function fetchQueuedTasks() {
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
// ==================== Scheduled Reminder Bridge ====================
|
|
554
|
-
|
|
555
|
-
async function fetchScheduledTodos() {
|
|
556
|
-
try {
|
|
557
|
-
const machineId = getMachineId();
|
|
558
|
-
const params = new URLSearchParams();
|
|
559
|
-
params.set('scheduled_before', new Date().toISOString());
|
|
560
|
-
if (machineId) {
|
|
561
|
-
params.set('machine_id', machineId);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Include registered git_remotes so backend can scope to this machine's projects
|
|
565
|
-
const projects = getListedProjects();
|
|
566
|
-
const gitRemotes = Object.keys(projects);
|
|
567
|
-
const headers = {};
|
|
568
|
-
if (machineId && gitRemotes.length > 0) {
|
|
569
|
-
headers['X-Machine-Id'] = machineId;
|
|
570
|
-
headers['X-Git-Remotes'] = gitRemotes.join(',');
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const response = await apiRequest(`synced-todos?${params}`, { headers });
|
|
574
|
-
if (!response.ok) return [];
|
|
575
|
-
|
|
576
|
-
const data = await response.json();
|
|
577
|
-
return data.todos || [];
|
|
578
|
-
} catch (error) {
|
|
579
|
-
logError(`Failed to fetch scheduled todos: ${error.message}`);
|
|
580
|
-
return [];
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
async function checkAndQueueScheduledTodos() {
|
|
585
|
-
const scheduledTodos = await fetchScheduledTodos();
|
|
586
|
-
if (scheduledTodos.length === 0) return;
|
|
587
|
-
|
|
588
|
-
const registeredProjects = getListedProjects();
|
|
589
|
-
|
|
590
|
-
for (const todo of scheduledTodos) {
|
|
591
|
-
const dn = todo.displayNumber || todo.display_number;
|
|
592
|
-
const todoId = todo.id;
|
|
593
|
-
|
|
594
|
-
// Skip tasks already in an execution state (running, queued, completed, failed, etc.)
|
|
595
|
-
const execStatus = todo.executionStatus || todo.execution_status;
|
|
596
|
-
if (execStatus && execStatus !== 'none') {
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Skip completed tasks
|
|
601
|
-
if (todo.isCompleted || todo.is_completed) {
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Skip tasks whose project is not registered on this machine
|
|
606
|
-
const gitRemote = todo.gitRemote || todo.git_remote;
|
|
607
|
-
if (gitRemote && Object.keys(registeredProjects).length > 0) {
|
|
608
|
-
if (!(gitRemote in registeredProjects)) {
|
|
609
|
-
log(`Schedule skipped #${dn}: project ${gitRemote} not registered on this machine`);
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
log(`Schedule triggered for #${dn} (reminder_date: ${todo.reminderDate || todo.reminder_date})`);
|
|
615
|
-
|
|
616
|
-
try {
|
|
617
|
-
await updateTaskStatus(dn, 'queued', {
|
|
618
|
-
event: {
|
|
619
|
-
type: 'scheduled_trigger',
|
|
620
|
-
timestamp: new Date().toISOString(),
|
|
621
|
-
summary: 'Auto-queued: reminder time reached',
|
|
622
|
-
}
|
|
623
|
-
}, todoId);
|
|
624
|
-
|
|
625
|
-
log(`Queued #${dn} via schedule trigger`);
|
|
626
|
-
} catch (error) {
|
|
627
|
-
logError(`Failed to queue scheduled todo #${dn}: ${error.message}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
553
|
// ==================== Task Status Updates ====================
|
|
633
554
|
|
|
634
555
|
async function updateTaskStatus(displayNumber, status, extra = {}, todoId = null) {
|
|
@@ -3239,18 +3160,11 @@ async function mainLoop() {
|
|
|
3239
3160
|
try {
|
|
3240
3161
|
await checkTimeouts();
|
|
3241
3162
|
|
|
3242
|
-
// Schedule bridge: auto-queue todos whose reminder time has arrived
|
|
3243
|
-
try {
|
|
3244
|
-
await checkAndQueueScheduledTodos();
|
|
3245
|
-
} catch (error) {
|
|
3246
|
-
logError(`Scheduled todo check error: ${error.message}`);
|
|
3247
|
-
}
|
|
3248
|
-
|
|
3249
3163
|
await pollAndExecute();
|
|
3250
3164
|
|
|
3251
3165
|
// Remote schedules from Supabase
|
|
3252
3166
|
try {
|
|
3253
|
-
await checkAndRunRemoteSchedules(log, { apiRequest });
|
|
3167
|
+
await checkAndRunRemoteSchedules(log, { apiRequest, machineId: getMachineId() });
|
|
3254
3168
|
} catch (error) {
|
|
3255
3169
|
logError(`Remote schedules error: ${error.message}`);
|
|
3256
3170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masslessai/push-todo",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"description": "Voice tasks from Push iOS app for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"hooks/",
|
|
20
20
|
"natives/",
|
|
21
21
|
"scripts/",
|
|
22
|
+
"skills/",
|
|
22
23
|
"SKILL.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
package/scripts/postinstall.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* 1. Claude Code - symlink to ~/.claude/skills/ (gives clean /push-todo command)
|
|
7
7
|
* 2. OpenAI Codex - AGENTS.md in ~/.codex/
|
|
8
8
|
* 3. OpenClaw - SKILL.md in ~/.openclaw/skills/ (legacy: ~/.clawdbot/)
|
|
9
|
-
* 4.
|
|
9
|
+
* 4. Bundled skills - auto-discovers bundled skills and creates per-skill symlinks
|
|
10
|
+
* 5. Downloads native keychain helper binary (macOS)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, rmSync, appendFileSync } from 'fs';
|
|
13
|
+
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, rmSync, appendFileSync, readdirSync } from 'fs';
|
|
13
14
|
import { chmod, stat } from 'fs/promises';
|
|
14
15
|
import { pipeline } from 'stream/promises';
|
|
15
16
|
import { join, dirname } from 'path';
|
|
@@ -301,6 +302,68 @@ function setupOpenClaw() {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Auto-discover bundled skills and create per-skill symlinks.
|
|
307
|
+
* Each bundled skill gets: <targetDir>/push-<name> -> PACKAGE_ROOT/skills/<name>
|
|
308
|
+
*
|
|
309
|
+
* Claude Code scans one level deep (~/.claude/skills/X/SKILL.md), but bundled
|
|
310
|
+
* skills are two levels deep from the root push-todo symlink. Per-skill symlinks
|
|
311
|
+
* give each bundled skill its own top-level entry for independent discovery.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} targetSkillsDir - e.g. ~/.claude/skills/
|
|
314
|
+
* @param {string} clientLabel - e.g. "Claude Code"
|
|
315
|
+
* @returns {string[]} names of skills that were symlinked
|
|
316
|
+
*/
|
|
317
|
+
function setupBundledSkills(targetSkillsDir, clientLabel) {
|
|
318
|
+
const bundledDir = join(PACKAGE_ROOT, 'skills');
|
|
319
|
+
if (!existsSync(bundledDir)) return [];
|
|
320
|
+
|
|
321
|
+
let entries;
|
|
322
|
+
try {
|
|
323
|
+
entries = readdirSync(bundledDir);
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const installed = [];
|
|
329
|
+
|
|
330
|
+
for (const name of entries) {
|
|
331
|
+
const skillSource = join(bundledDir, name);
|
|
332
|
+
const skillMd = join(skillSource, 'SKILL.md');
|
|
333
|
+
|
|
334
|
+
// Only process directories containing SKILL.md
|
|
335
|
+
if (!existsSync(skillMd)) continue;
|
|
336
|
+
|
|
337
|
+
// Namespace with push- prefix (skip if already prefixed)
|
|
338
|
+
const linkName = name.startsWith('push-') ? name : `push-${name}`;
|
|
339
|
+
const linkPath = join(targetSkillsDir, linkName);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
if (existsSync(linkPath)) {
|
|
343
|
+
const stats = lstatSync(linkPath);
|
|
344
|
+
if (stats.isSymbolicLink()) {
|
|
345
|
+
const currentTarget = readlinkSync(linkPath);
|
|
346
|
+
if (currentTarget === skillSource) {
|
|
347
|
+
installed.push(linkName);
|
|
348
|
+
continue; // Already correct
|
|
349
|
+
}
|
|
350
|
+
unlinkSync(linkPath);
|
|
351
|
+
} else {
|
|
352
|
+
rmSync(linkPath, { recursive: true });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
symlinkSync(skillSource, linkPath);
|
|
357
|
+
installed.push(linkName);
|
|
358
|
+
console.log(`[push-todo] ${clientLabel}: Bundled skill installed: ${linkName}`);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
console.log(`[push-todo] ${clientLabel}: Failed to install bundled skill ${linkName}: ${err.message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return installed;
|
|
365
|
+
}
|
|
366
|
+
|
|
304
367
|
/**
|
|
305
368
|
* Download a file from URL to destination.
|
|
306
369
|
*
|
|
@@ -356,15 +419,36 @@ async function main() {
|
|
|
356
419
|
// Step 3: Set up Claude Code skill symlink
|
|
357
420
|
console.log('[push-todo] Setting up Claude Code skill...');
|
|
358
421
|
const claudeSuccess = setupClaudeSkill();
|
|
422
|
+
if (claudeSuccess) {
|
|
423
|
+
const bundled = setupBundledSkills(SKILL_DIR, 'Claude Code');
|
|
424
|
+
if (bundled.length > 0) {
|
|
425
|
+
console.log(`[push-todo] Claude Code: ${bundled.length} bundled skill(s) installed`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
359
428
|
console.log('');
|
|
360
429
|
|
|
361
430
|
// Step 4: Set up OpenAI Codex (if installed)
|
|
362
431
|
const codexSuccess = setupCodex();
|
|
363
|
-
if (codexSuccess)
|
|
432
|
+
if (codexSuccess) {
|
|
433
|
+
const codexSkillsDir = join(CODEX_DIR, 'skills');
|
|
434
|
+
const bundled = setupBundledSkills(codexSkillsDir, 'Codex');
|
|
435
|
+
if (bundled.length > 0) {
|
|
436
|
+
console.log(`[push-todo] Codex: ${bundled.length} bundled skill(s) installed`);
|
|
437
|
+
}
|
|
438
|
+
console.log('');
|
|
439
|
+
}
|
|
364
440
|
|
|
365
441
|
// Step 5: Set up OpenClaw (if installed — formerly Clawdbot)
|
|
366
442
|
const openclawSuccess = setupOpenClaw();
|
|
367
|
-
if (openclawSuccess)
|
|
443
|
+
if (openclawSuccess) {
|
|
444
|
+
const clawDir = existsSync(OPENCLAW_DIR) ? OPENCLAW_DIR : OPENCLAW_LEGACY_DIR;
|
|
445
|
+
const clawSkillsDir = join(clawDir, 'skills');
|
|
446
|
+
const bundled = setupBundledSkills(clawSkillsDir, 'OpenClaw');
|
|
447
|
+
if (bundled.length > 0) {
|
|
448
|
+
console.log(`[push-todo] OpenClaw: ${bundled.length} bundled skill(s) installed`);
|
|
449
|
+
}
|
|
450
|
+
console.log('');
|
|
451
|
+
}
|
|
368
452
|
|
|
369
453
|
// Track which clients were set up
|
|
370
454
|
const clients = [];
|