social-autoposter 1.6.135 → 1.6.136
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/mcp/dist/index.js +47 -50
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +92 -128
- package/mcp/package.json +1 -1
- package/package.json +1 -1
package/mcp/dist/index.js
CHANGED
|
@@ -2607,66 +2607,63 @@ async function ensureMemorySnapshotInstalled() {
|
|
|
2607
2607
|
return { ok: false, detail: e?.message || String(e) };
|
|
2608
2608
|
}
|
|
2609
2609
|
}
|
|
2610
|
-
//
|
|
2611
|
-
//
|
|
2612
|
-
//
|
|
2613
|
-
//
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
if (k.startsWith("dxt:allowlistLastUpdated:") && typeof v === "string" && v > bestTs) {
|
|
2622
|
-
bestTs = v;
|
|
2623
|
-
bestId = k.slice("dxt:allowlistLastUpdated:".length);
|
|
2624
|
-
}
|
|
2625
|
-
}
|
|
2626
|
-
return bestId;
|
|
2627
|
-
}
|
|
2628
|
-
catch {
|
|
2629
|
-
return null;
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
// The ACTUAL schedule state for the current account (read, not inferred from
|
|
2633
|
-
// firing): 'missing' | 'disabled' | 'ok' | 'unknown'. Drives the dashboard's
|
|
2634
|
-
// "Set up draft schedule" button. Mirrors s4l_menubar.py::_schedule_state.
|
|
2610
|
+
// Is the draft schedule registered AND running for the LIVE account?
|
|
2611
|
+
// 'ok' — worker tasks present+enabled and FIRING (lastRunAt within
|
|
2612
|
+
// SCHEDULE_FIRING_MS) — the host is actively running them.
|
|
2613
|
+
// 'disabled' — present but a worker task is disabled.
|
|
2614
|
+
// 'missing' — not firing anywhere (orphaned / not registered for the live
|
|
2615
|
+
// account) -> dashboard offers "Set up draft schedule".
|
|
2616
|
+
// The live account is identified by the registry the host is actually FIRING
|
|
2617
|
+
// (freshest lastRunAt — only the active account's scheduler advances it), NOT a
|
|
2618
|
+
// session id (which churns on every Claude restart and mis-read "missing" while
|
|
2619
|
+
// the tasks were firing). Mirrors s4l_menubar.py::_schedule_state.
|
|
2620
|
+
const SCHEDULE_FIRING_MS = 420_000; // 7 min; tolerates throttle + restart gaps
|
|
2635
2621
|
function scheduleState() {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
let
|
|
2622
|
+
let newestMs = null;
|
|
2623
|
+
let newestEnabled = false;
|
|
2624
|
+
let anyPresent = false;
|
|
2625
|
+
let anyEnabled = false;
|
|
2640
2626
|
try {
|
|
2641
2627
|
const base = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
2642
2628
|
for (const ws of fs.readdirSync(base)) {
|
|
2643
|
-
const
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2629
|
+
for (const inner of fs.readdirSync(path.join(base, ws))) {
|
|
2630
|
+
const reg = path.join(base, ws, inner, "scheduled-tasks.json");
|
|
2631
|
+
let d;
|
|
2632
|
+
try {
|
|
2633
|
+
d = JSON.parse(fs.readFileSync(reg, "utf-8"));
|
|
2634
|
+
}
|
|
2635
|
+
catch {
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
const byId = {};
|
|
2639
|
+
for (const t of d.scheduledTasks || [])
|
|
2640
|
+
byId[t.id] = t;
|
|
2641
|
+
const recs = QUEUE_WORKERS.map((s) => byId[s.taskId]);
|
|
2642
|
+
if (recs.some((r) => !r))
|
|
2643
|
+
continue;
|
|
2644
|
+
anyPresent = true;
|
|
2645
|
+
const enabled = recs.every((r) => r.enabled);
|
|
2646
|
+
anyEnabled = anyEnabled || enabled;
|
|
2647
|
+
const epochs = recs
|
|
2648
|
+
.map((r) => Date.parse(r.lastRunAt || ""))
|
|
2649
|
+
.filter((n) => !Number.isNaN(n));
|
|
2650
|
+
const e = epochs.length ? Math.max(...epochs) : null;
|
|
2651
|
+
if (e !== null && (newestMs === null || e > newestMs)) {
|
|
2652
|
+
newestMs = e;
|
|
2653
|
+
newestEnabled = enabled;
|
|
2654
|
+
}
|
|
2647
2655
|
}
|
|
2648
2656
|
}
|
|
2649
2657
|
}
|
|
2650
2658
|
catch {
|
|
2651
|
-
/* base missing */
|
|
2652
|
-
}
|
|
2653
|
-
if (!regPath)
|
|
2654
|
-
return "missing";
|
|
2655
|
-
try {
|
|
2656
|
-
const d = JSON.parse(fs.readFileSync(regPath, "utf-8"));
|
|
2657
|
-
const byId = {};
|
|
2658
|
-
for (const t of d.scheduledTasks || [])
|
|
2659
|
-
byId[t.id] = t;
|
|
2660
|
-
const recs = QUEUE_WORKERS.map((s) => byId[s.taskId]);
|
|
2661
|
-
if (recs.some((r) => !r))
|
|
2662
|
-
return "missing";
|
|
2663
|
-
if (recs.some((r) => !r.enabled))
|
|
2664
|
-
return "disabled";
|
|
2665
|
-
return "ok";
|
|
2659
|
+
/* base missing -> treat as missing */
|
|
2666
2660
|
}
|
|
2667
|
-
|
|
2668
|
-
return "
|
|
2661
|
+
if (newestMs !== null && Date.now() - newestMs <= SCHEDULE_FIRING_MS) {
|
|
2662
|
+
return newestEnabled ? "ok" : "disabled";
|
|
2669
2663
|
}
|
|
2664
|
+
if (anyPresent && !anyEnabled)
|
|
2665
|
+
return "disabled";
|
|
2666
|
+
return "missing";
|
|
2670
2667
|
}
|
|
2671
2668
|
// Assemble everything the panel needs in one shot (projects + X + autopilot +
|
|
2672
2669
|
// version). Resilient: any probe that throws degrades to a safe default rather
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.136",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "The disclaimer above is generic Claude boilerplate. S4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
|
@@ -133,6 +133,12 @@ REARM_PROMPT = (
|
|
|
133
133
|
# scheduler's per-minute cadence + a slow claim.
|
|
134
134
|
AUTOPILOT_STALL_SECONDS = 180
|
|
135
135
|
|
|
136
|
+
# A worker task whose lastRunAt is within this many seconds is "firing" — the host
|
|
137
|
+
# scheduler runs them every minute, so a fresh stamp means the live account's
|
|
138
|
+
# schedule is active. 7 min tolerates host throttling + a restart gap without
|
|
139
|
+
# false "not scheduled". Used by _schedule_state.
|
|
140
|
+
FIRING_WINDOW = 420
|
|
141
|
+
|
|
136
142
|
|
|
137
143
|
def _glyph(status):
|
|
138
144
|
return GLYPH.get(status, "·")
|
|
@@ -356,92 +362,80 @@ class S4LMenuBar(rumps.App):
|
|
|
356
362
|
do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
|
|
357
363
|
(can't reliably target a just-switched-into account)."""
|
|
358
364
|
copied = self._copy_to_clipboard(REARM_PROMPT)
|
|
359
|
-
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
365
|
+
# Show the modal BEFORE opening Claude. If we raise Claude first, the NSAlert
|
|
366
|
+
# renders BEHIND it (invisible) while still blocking the main thread, which
|
|
367
|
+
# is what made the menu look frozen/greyed with no visible dialog. Showing
|
|
368
|
+
# it first keeps it frontmost; we open Claude after the user dismisses it.
|
|
369
|
+
# (_notify's osascript notification silently no-ops from this bundle-less
|
|
370
|
+
# launchd process, so a modal is the only reliable feedback.)
|
|
371
|
+
msg = (
|
|
372
|
+
"The setup prompt is copied to your clipboard.\n\nClick OK, then click into "
|
|
373
|
+
"the Claude chat, paste it (Cmd+V), and press Enter — that schedules the "
|
|
374
|
+
"draft tasks for this account."
|
|
375
|
+
if copied
|
|
376
|
+
else "Click OK, then open Claude and ask it to set up the draft schedule for this account."
|
|
377
|
+
)
|
|
364
378
|
try:
|
|
365
|
-
|
|
366
|
-
rumps.alert(
|
|
367
|
-
title="Paste in Claude to finish",
|
|
368
|
-
message=(
|
|
369
|
-
"The setup prompt is copied to your clipboard.\n\n"
|
|
370
|
-
"Click into the Claude chat, paste it (Cmd+V), and press Enter. "
|
|
371
|
-
"That schedules the draft tasks for this account."
|
|
372
|
-
),
|
|
373
|
-
ok="Got it",
|
|
374
|
-
)
|
|
375
|
-
else:
|
|
376
|
-
rumps.alert(
|
|
377
|
-
title="Set up the draft schedule",
|
|
378
|
-
message="Open Claude and ask it to set up the draft schedule for this account.",
|
|
379
|
-
ok="OK",
|
|
380
|
-
)
|
|
379
|
+
rumps.alert(title="Set up the draft schedule", message=msg, ok="OK")
|
|
381
380
|
except Exception:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"Paste it into Claude (Cmd+V) and press Enter to schedule the draft tasks.",
|
|
386
|
-
)
|
|
381
|
+
self._notify("S4L · setup prompt copied" if copied else "S4L",
|
|
382
|
+
"Paste the prompt into Claude (Cmd+V) and press Enter.")
|
|
383
|
+
self._open_claude()
|
|
387
384
|
|
|
388
|
-
# ---- schedule-state detection
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
385
|
+
# ---- schedule-state detection ----------------------------------------
|
|
386
|
+
# Identify the LIVE account's registry by which one the host is actually
|
|
387
|
+
# FIRING (freshest lastRunAt — only the active account's scheduler advances
|
|
388
|
+
# it), then read THAT registry's enabled state. This is robust across the
|
|
389
|
+
# session-id churn that restarts cause; the old "active session = newest
|
|
390
|
+
# config.json allowlist key" heuristic mis-reported "missing" after a restart
|
|
391
|
+
# even while the tasks were firing.
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _iso_to_epoch(s):
|
|
394
|
+
if not s:
|
|
395
|
+
return None
|
|
395
396
|
try:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
best_id, best_ts = None, ""
|
|
401
|
-
for k, v in d.items():
|
|
402
|
-
if k.startswith("dxt:allowlistLastUpdated:") and isinstance(v, str):
|
|
403
|
-
if v > best_ts: # ISO-8601 sorts lexically by time
|
|
404
|
-
best_ts, best_id = v, k.split(":", 2)[2]
|
|
405
|
-
return best_id
|
|
397
|
+
import calendar
|
|
398
|
+
return calendar.timegm(
|
|
399
|
+
time.strptime(str(s).strip().rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S")
|
|
400
|
+
)
|
|
406
401
|
except Exception:
|
|
407
402
|
return None
|
|
408
403
|
|
|
409
|
-
def _active_session_registry_path(self):
|
|
410
|
-
"""Path to the current account's scheduled-tasks.json (claude-code-sessions/
|
|
411
|
-
<workspace>/<active-session>/...). None if the active session can't be found
|
|
412
|
-
or its dir doesn't exist yet (brand-new account -> schedule absent)."""
|
|
413
|
-
sid = self._active_session_id()
|
|
414
|
-
if not sid:
|
|
415
|
-
return None
|
|
416
|
-
hits = glob.glob(os.path.join(
|
|
417
|
-
os.path.expanduser("~"), "Library", "Application Support", "Claude",
|
|
418
|
-
"claude-code-sessions", "*", sid, "scheduled-tasks.json"))
|
|
419
|
-
return hits[0] if hits else None
|
|
420
|
-
|
|
421
404
|
def _schedule_state(self):
|
|
422
|
-
"""
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'disabled' —
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
405
|
+
"""Is the draft schedule registered AND running for the live account?
|
|
406
|
+
'ok' — worker tasks present+enabled and FIRING (lastRunAt within
|
|
407
|
+
FIRING_WINDOW) — the host is actively running them.
|
|
408
|
+
'disabled' — present but a worker task is disabled.
|
|
409
|
+
'missing' — not firing anywhere (orphaned / not registered for the live
|
|
410
|
+
account) -> offer re-arm.
|
|
411
|
+
The active account is identified by the freshest-firing registry, NOT a
|
|
412
|
+
session id (which churns on restart)."""
|
|
413
|
+
newest_epoch, newest_enabled = None, False
|
|
414
|
+
any_present, any_enabled = False, False
|
|
415
|
+
for f in glob.glob(SCHED_REGISTRY_GLOB):
|
|
416
|
+
try:
|
|
417
|
+
with open(f) as fh:
|
|
418
|
+
d = json.load(fh)
|
|
419
|
+
except Exception:
|
|
420
|
+
continue
|
|
421
|
+
by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
|
|
422
|
+
recs = [by_id.get(tid) for tid in WORKER_TASK_IDS]
|
|
423
|
+
if any(r is None for r in recs):
|
|
424
|
+
continue
|
|
425
|
+
any_present = True
|
|
426
|
+
enabled = all(r.get("enabled") for r in recs)
|
|
427
|
+
any_enabled = any_enabled or enabled
|
|
428
|
+
epochs = [self._iso_to_epoch(r.get("lastRunAt")) for r in recs]
|
|
429
|
+
e = max([x for x in epochs if x is not None], default=None)
|
|
430
|
+
if e is not None and (newest_epoch is None or e > newest_epoch):
|
|
431
|
+
newest_epoch, newest_enabled = e, enabled
|
|
432
|
+
# Firing recently => the live account's schedule is active and healthy.
|
|
433
|
+
if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
|
|
434
|
+
return "ok" if newest_enabled else "disabled"
|
|
435
|
+
# Not firing anywhere. Registered-but-disabled => disabled; else missing.
|
|
436
|
+
if any_present and not any_enabled:
|
|
443
437
|
return "disabled"
|
|
444
|
-
return "
|
|
438
|
+
return "missing"
|
|
445
439
|
|
|
446
440
|
# ---- autopilot liveness (the false-green fix) -------------------------
|
|
447
441
|
def _autopilot_stalled(self):
|
|
@@ -1019,21 +1013,17 @@ class S4LMenuBar(rumps.App):
|
|
|
1019
1013
|
blocker = (ob or {}).get("current_blocker")
|
|
1020
1014
|
blocker_code = (blocker or {}).get("code")
|
|
1021
1015
|
# --- Autopilot health (only meaningful once setup is complete) --------
|
|
1022
|
-
#
|
|
1023
|
-
# (
|
|
1024
|
-
# (
|
|
1025
|
-
#
|
|
1016
|
+
# SINGLE signal: is the draft schedule registered AND firing for the live
|
|
1017
|
+
# account (schedule_state)? 'ok' = the host is running the tasks -> healthy,
|
|
1018
|
+
# NO warning (even if no draft has drained yet — that's just an empty queue
|
|
1019
|
+
# between cycles, not a setup problem). 'missing'/'disabled' = not running
|
|
1020
|
+
# for this account -> show re-arm. We deliberately do NOT drive the menu off
|
|
1021
|
+
# the drain-status latch anymore: it stayed stale after recovery and made a
|
|
1022
|
+
# firing, healthy autopilot look "not set up".
|
|
1026
1023
|
schedule_state = self._schedule_state() if setup_complete else "ok"
|
|
1027
|
-
stalled = setup_complete and self._autopilot_stalled()
|
|
1028
1024
|
self._schedule_state_cache = schedule_state
|
|
1029
|
-
|
|
1030
|
-
#
|
|
1031
|
-
self._stall_reason_info = (
|
|
1032
|
-
self._stall_reason() if (stalled and schedule_state in ("ok", "unknown")) else ("", "")
|
|
1033
|
-
)
|
|
1034
|
-
# Any non-healthy state drops the stale "drafting" spinner so the ⚠ in the
|
|
1035
|
-
# title isn't masked (see _poll_activity).
|
|
1036
|
-
attention = setup_complete and (schedule_state in ("missing", "disabled") or stalled)
|
|
1025
|
+
attention = setup_complete and schedule_state in ("missing", "disabled")
|
|
1026
|
+
# Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
|
|
1037
1027
|
self._stalled = attention
|
|
1038
1028
|
|
|
1039
1029
|
# Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
|
|
@@ -1047,32 +1037,19 @@ class S4LMenuBar(rumps.App):
|
|
|
1047
1037
|
blocker.get("message", "Setup is blocked"),
|
|
1048
1038
|
)
|
|
1049
1039
|
self._last_blocker_code = blocker_code
|
|
1050
|
-
# Notify once per episode
|
|
1040
|
+
# Notify once per episode (the draft schedule isn't running for this account).
|
|
1051
1041
|
if attention and not self._stall_notified:
|
|
1052
|
-
|
|
1053
|
-
if schedule_state == "missing":
|
|
1054
|
-
self._notify(
|
|
1055
|
-
"S4L draft autopilot not scheduled",
|
|
1056
|
-
"No draft tasks are scheduled on this Claude account (switching "
|
|
1057
|
-
"accounts clears them). Open the S4L menu → “Set up draft schedule”.",
|
|
1058
|
-
)
|
|
1059
|
-
elif schedule_state == "disabled":
|
|
1042
|
+
if schedule_state == "disabled":
|
|
1060
1043
|
self._notify(
|
|
1061
1044
|
"S4L draft tasks disabled",
|
|
1062
1045
|
"The draft tasks are scheduled but disabled. Open the S4L menu → "
|
|
1063
1046
|
"“Set up draft schedule” to re-enable.",
|
|
1064
1047
|
)
|
|
1065
|
-
elif kind == "rate_limited":
|
|
1066
|
-
self._notify(
|
|
1067
|
-
"S4L autopilot paused",
|
|
1068
|
-
"Claude usage limit reached" + (f" ({detail})" if detail else "")
|
|
1069
|
-
+ ". Drafting resumes at reset, or sign into an account with quota.",
|
|
1070
|
-
)
|
|
1071
1048
|
else:
|
|
1072
1049
|
self._notify(
|
|
1073
|
-
"S4L autopilot
|
|
1074
|
-
"
|
|
1075
|
-
"S4L menu
|
|
1050
|
+
"S4L draft autopilot not scheduled",
|
|
1051
|
+
"No draft tasks are running on this Claude account (switching "
|
|
1052
|
+
"accounts clears them). Open the S4L menu → “Set up draft schedule”.",
|
|
1076
1053
|
)
|
|
1077
1054
|
self._stall_notified = True
|
|
1078
1055
|
elif not attention:
|
|
@@ -1359,29 +1336,16 @@ class S4LMenuBar(rumps.App):
|
|
|
1359
1336
|
items.append(header)
|
|
1360
1337
|
items.append(rumps.separator)
|
|
1361
1338
|
|
|
1362
|
-
#
|
|
1363
|
-
#
|
|
1364
|
-
#
|
|
1365
|
-
#
|
|
1366
|
-
# aren't draining do we fall to the stall-reason wording (rate-limit/other),
|
|
1367
|
-
# where re-creating the schedule wouldn't help.
|
|
1339
|
+
# Attention = the draft schedule isn't running for THIS account (missing or
|
|
1340
|
+
# disabled). "Set up draft schedule" fixes it via host create_scheduled_task.
|
|
1341
|
+
# When the schedule IS firing (ok), attention is False and nothing shows here
|
|
1342
|
+
# — a firing autopilot reads as healthy even if no draft has drained yet.
|
|
1368
1343
|
if attention:
|
|
1369
|
-
if schedule_state == "
|
|
1370
|
-
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
1371
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
1372
|
-
elif schedule_state == "disabled":
|
|
1344
|
+
if schedule_state == "disabled":
|
|
1373
1345
|
items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
|
|
1374
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
1375
1346
|
else:
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
items.append(self._label("⚠ Autopilot paused — Claude usage limit"))
|
|
1379
|
-
if detail:
|
|
1380
|
-
items.append(self._label(f" {detail}"))
|
|
1381
|
-
items.append(self._label(" Resumes at reset, or switch Claude account"))
|
|
1382
|
-
else:
|
|
1383
|
-
items.append(self._label("⚠ Scheduled, but no drafts are being produced"))
|
|
1384
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
1347
|
+
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
1348
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
1385
1349
|
items.append(rumps.separator)
|
|
1386
1350
|
|
|
1387
1351
|
if not runtime_ready:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.136",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED