@tekmidian/pai 0.13.5 → 0.14.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.
@@ -6,7 +6,7 @@ import "../db-CmYbAVCD.mjs";
6
6
  import "../helpers-IjZkXBhj.mjs";
7
7
  import "../embeddings-BJPOcbik.mjs";
8
8
  import "../search-HcdKtMla.mjs";
9
- import "../pick-CsHZ8Abv.mjs";
9
+ import "../pick-BPlB38eB.mjs";
10
10
  import "../kg-extraction-C8DEUHTS.mjs";
11
11
  import "../factory-Q88X1bAN.mjs";
12
12
  import "../config-C8m-tPhP.mjs";
@@ -5,7 +5,7 @@ import "../db-CmYbAVCD.mjs";
5
5
  import "../helpers-IjZkXBhj.mjs";
6
6
  import "../embeddings-BJPOcbik.mjs";
7
7
  import "../search-HcdKtMla.mjs";
8
- import { C as registerDaemonCommands, D as registerProjectsCommands, E as registerRegistryCommands, O as findMovedPath, S as registerBackupCommands, T as registerMemoryCommands, _ as registerObservationCommands, a as cmdPauseAll, b as registerSetupCommand, c as cmdPause, d as registerKgCommands, f as registerTopicCommands, g as registerSkillCommands, h as registerUpdateCommand, i as cmdClearNames, k as resolveIdentifier, l as registerHelpCommand, m as registerNotifyCommands, n as cmdFind, o as cmdGoto, p as registerTaskCommands, r as cmdList, s as cmdEnd, t as cmdPick, u as registerDbCommands, v as registerZettelCommands, w as registerMcpCommands, x as registerRestoreCommands, y as registerObsidianCommands } from "../pick-CsHZ8Abv.mjs";
8
+ import { C as registerDaemonCommands, D as registerProjectsCommands, E as registerRegistryCommands, O as findMovedPath, S as registerBackupCommands, T as registerMemoryCommands, _ as registerObservationCommands, a as cmdPauseAll, b as registerSetupCommand, c as cmdPause, d as registerKgCommands, f as registerTopicCommands, g as registerSkillCommands, h as registerUpdateCommand, i as cmdClearNames, k as resolveIdentifier, l as registerHelpCommand, m as registerNotifyCommands, n as cmdFind, o as cmdGoto, p as registerTaskCommands, r as cmdList, s as cmdEnd, t as cmdPick, u as registerDbCommands, v as registerZettelCommands, w as registerMcpCommands, x as registerRestoreCommands, y as registerObsidianCommands } from "../pick-BPlB38eB.mjs";
9
9
  import "../kg-extraction-C8DEUHTS.mjs";
10
10
  import "../factory-Q88X1bAN.mjs";
11
11
  import "../config-C8m-tPhP.mjs";
@@ -4328,6 +4328,33 @@ var TodoistProvider = class {
4328
4328
  sourceUrl: `https://app.todoist.com/app/task/${created.id}`
4329
4329
  };
4330
4330
  }
4331
+ /**
4332
+ * Replace a task's labels.
4333
+ *
4334
+ * Only ever sends `labels`. Never `due_date`: writing that field destroys a
4335
+ * recurrence rule, silently turning a routine into a one-off. Measured — it
4336
+ * cost me an invalid test before it cost anyone a schedule.
4337
+ */
4338
+ async setLabels(id, labels) {
4339
+ const token = this.token();
4340
+ if (!token) throw new Error("Todoist provider is not configured — run `pai task config`.");
4341
+ await call(token, `/tasks/${id}`, {
4342
+ method: "POST",
4343
+ body: { labels }
4344
+ });
4345
+ }
4346
+ /** Append a comment — used to keep run history on the task itself. */
4347
+ async comment(id, content) {
4348
+ const token = this.token();
4349
+ if (!token) throw new Error("Todoist provider is not configured — run `pai task config`.");
4350
+ await call(token, `/comments`, {
4351
+ method: "POST",
4352
+ body: {
4353
+ task_id: id,
4354
+ content
4355
+ }
4356
+ });
4357
+ }
4331
4358
  async complete(id) {
4332
4359
  const token = this.token();
4333
4360
  if (!token) throw new Error("Todoist provider is not configured — run `pai setup`.");
@@ -6735,6 +6762,80 @@ var AiBrokerTransport = class {
6735
6762
  }
6736
6763
  };
6737
6764
  /**
6765
+ * Liveness prober backed by `aibroker ask`.
6766
+ *
6767
+ * Separate from Transport on purpose: asking must NEVER spawn. A probe that
6768
+ * creates the session it is probing for turns "this session died" into "all is
6769
+ * well", which is the failure the probe exists to catch.
6770
+ */
6771
+ var AiBrokerProber = class {
6772
+ constructor(bin = "aibroker", timeoutSecs = 60) {
6773
+ this.bin = bin;
6774
+ this.timeoutSecs = timeoutSecs;
6775
+ }
6776
+ async ask(project, question) {
6777
+ let stdout;
6778
+ try {
6779
+ stdout = await run(this.bin, [
6780
+ "ask",
6781
+ project,
6782
+ "--stdin",
6783
+ "--json",
6784
+ "--timeout",
6785
+ String(this.timeoutSecs)
6786
+ ], question, this.timeoutSecs * 1e3 + KILL_MARGIN_MS);
6787
+ } catch (e) {
6788
+ return {
6789
+ state: "silent",
6790
+ reason: e instanceof Error ? e.message : String(e)
6791
+ };
6792
+ }
6793
+ const start = stdout.lastIndexOf("{");
6794
+ if (start === -1) return {
6795
+ state: "silent",
6796
+ reason: "aibroker returned no JSON"
6797
+ };
6798
+ try {
6799
+ const wire = JSON.parse(stdout.slice(start));
6800
+ const state = wire.state ?? (wire.replied === true ? "replied" : "silent");
6801
+ if (state === "replied" || state === "busy" || state === "silent" || state === "absent") return {
6802
+ state,
6803
+ reply: wire.reply,
6804
+ reason: wire.reason
6805
+ };
6806
+ return {
6807
+ state: "silent",
6808
+ reason: `unexpected state from aibroker: ${String(wire.state)}`
6809
+ };
6810
+ } catch {
6811
+ return {
6812
+ state: "silent",
6813
+ reason: "aibroker returned malformed JSON"
6814
+ };
6815
+ }
6816
+ }
6817
+ };
6818
+ /**
6819
+ * Return a prober if the installed `aibroker` supports `ask`.
6820
+ *
6821
+ * Probes for the subcommand rather than the binary: an older AIBroker would
6822
+ * otherwise fail once per stuck task instead of the scheduler simply knowing it
6823
+ * has no liveness check and saying so.
6824
+ */
6825
+ async function detectProber(bin = "aibroker") {
6826
+ try {
6827
+ const help = await new Promise((resolve, reject) => {
6828
+ execFile(bin, ["help"], { timeout: 1e4 }, (error, stdout, stderr) => {
6829
+ if (error && !stdout) reject(error);
6830
+ else resolve(stdout + stderr);
6831
+ });
6832
+ });
6833
+ return /\bask\b/.test(help) ? new AiBrokerProber(bin) : null;
6834
+ } catch {
6835
+ return null;
6836
+ }
6837
+ }
6838
+ /**
6738
6839
  * Return a transport if the `aibroker` CLI is present and supports dispatch.
6739
6840
  *
6740
6841
  * Probing for the subcommand rather than just the binary matters: AIBroker
@@ -6754,6 +6855,403 @@ async function detectAiBroker(bin = "aibroker", timeoutSecs = DEFAULT_DISPATCH_T
6754
6855
  }
6755
6856
  }
6756
6857
 
6858
+ //#endregion
6859
+ //#region src/tasks/scheduler.ts
6860
+ /** Set when dispatched, cleared when the due date advances. */
6861
+ const RUNNING_LABEL = "pai-running";
6862
+ /** `pai-skip-if-late:4h` — past that, the window is gone; wait for the next. */
6863
+ const SKIP_IF_LATE_RE = /^pai-skip-if-late:(\d+)([hm])$/i;
6864
+ /** Fallback when a task has no learned duration yet. */
6865
+ const DEFAULT_EXPECTED_MINUTES = 30;
6866
+ /** Probe once the run exceeds expected x this. */
6867
+ const PROBE_FACTOR = 1.5;
6868
+ /** Consecutive unanswered probes before a run counts as stuck. */
6869
+ const STUCK_AFTER_FAILED_PROBES = 3;
6870
+ const EMPTY_RUN_STATE = {
6871
+ startedAt: {},
6872
+ failedProbes: {}
6873
+ };
6874
+ function isRunning(task) {
6875
+ return task.labels.some((l) => l.toLowerCase() === RUNNING_LABEL);
6876
+ }
6877
+ /** Minutes after which a missed window is abandoned, or null for "always run". */
6878
+ function skipIfLateMinutes(task) {
6879
+ for (const label of task.labels) {
6880
+ const m = SKIP_IF_LATE_RE.exec(label.trim());
6881
+ if (m) {
6882
+ const n = Number.parseInt(m[1], 10);
6883
+ return m[2].toLowerCase() === "h" ? n * 60 : n;
6884
+ }
6885
+ }
6886
+ return null;
6887
+ }
6888
+ /**
6889
+ * How overdue a task is, in minutes. Negative means still in the future.
6890
+ *
6891
+ * A date-only due ("2026-08-01") is treated as due at the start of that day,
6892
+ * which is what Todoist shows the user.
6893
+ */
6894
+ function overdueMinutes(task, now) {
6895
+ if (!task.due) return Number.NEGATIVE_INFINITY;
6896
+ const due = task.due.length <= 10 ? `${task.due}T00:00:00` : task.due;
6897
+ const dueMs = new Date(due).getTime();
6898
+ if (Number.isNaN(dueMs)) return Number.NEGATIVE_INFINITY;
6899
+ return Math.floor((now - dueMs) / 6e4);
6900
+ }
6901
+ /**
6902
+ * Expected runtime for a task.
6903
+ *
6904
+ * The mean rather than the max, deliberately: with a liveness probe available,
6905
+ * guessing low costs one extra probe, while guessing high leaves a dead session
6906
+ * undetected for far longer. The probe is what makes a tight estimate safe.
6907
+ *
6908
+ * Runs that ended in stuck or re-dispatch must never be fed in here — one hung
6909
+ * run would inflate the mean and every later run would inherit a too-generous
6910
+ * threshold.
6911
+ */
6912
+ function expectedMinutes(observed, fallback = DEFAULT_EXPECTED_MINUTES) {
6913
+ const clean = observed.filter((n) => Number.isFinite(n) && n > 0);
6914
+ if (clean.length === 0) return fallback;
6915
+ const recent = clean.slice(-5);
6916
+ return Math.max(1, Math.round(recent.reduce((a, b) => a + b, 0) / recent.length));
6917
+ }
6918
+ /**
6919
+ * Decide what should happen to one task on this tick.
6920
+ *
6921
+ * Pure: no I/O, no clock reads. Everything comes in via options so the state
6922
+ * machine can be tested against a fixed clock rather than by waiting.
6923
+ */
6924
+ function decide(task, opts) {
6925
+ const { now, state } = opts;
6926
+ const running = isRunning(task);
6927
+ const overdue = overdueMinutes(task, now);
6928
+ const startedAt = state.startedAt[task.id];
6929
+ if (running) {
6930
+ if (overdue < 0) return {
6931
+ action: "complete",
6932
+ task,
6933
+ durationMinutes: startedAt ? Math.max(1, Math.round((now - startedAt) / 6e4)) : null
6934
+ };
6935
+ if (startedAt === void 0) return {
6936
+ action: "orphaned",
6937
+ task,
6938
+ reason: "marked running but no start time is known — probe before assuming anything"
6939
+ };
6940
+ const elapsed = Math.max(0, Math.round((now - startedAt) / 6e4));
6941
+ const expected = expectedMinutes(opts.history?.[task.id] ?? []);
6942
+ if (elapsed >= expected * PROBE_FACTOR) return {
6943
+ action: "probe",
6944
+ task,
6945
+ elapsedMinutes: elapsed,
6946
+ expectedMinutes: expected
6947
+ };
6948
+ return {
6949
+ action: "running",
6950
+ task,
6951
+ elapsedMinutes: elapsed
6952
+ };
6953
+ }
6954
+ if (overdue < 0) return {
6955
+ action: "wait",
6956
+ task
6957
+ };
6958
+ const limit = skipIfLateMinutes(task);
6959
+ if (limit !== null && overdue > limit) return {
6960
+ action: "skip",
6961
+ task,
6962
+ reason: `overdue by ${overdue}m, past its ${limit}m window — waiting for the next occurrence`
6963
+ };
6964
+ return {
6965
+ action: "dispatch",
6966
+ task,
6967
+ overdueMinutes: Math.max(0, overdue)
6968
+ };
6969
+ }
6970
+ /**
6971
+ * Order tasks for dispatch.
6972
+ *
6973
+ * Subtasks of the same parent run in Todoist's own order, so a routine is
6974
+ * sequenced by dragging its steps in the UI. Everything else falls back to
6975
+ * priority then due time, so the most overdue urgent thing goes first.
6976
+ */
6977
+ function dispatchOrder(tasks) {
6978
+ const rank = {
6979
+ p1: 0,
6980
+ p2: 1,
6981
+ p3: 2,
6982
+ p4: 3
6983
+ };
6984
+ return [...tasks].sort((a, b) => {
6985
+ const p = rank[a.priority] - rank[b.priority];
6986
+ if (p !== 0) return p;
6987
+ return (a.due ?? "").localeCompare(b.due ?? "");
6988
+ });
6989
+ }
6990
+
6991
+ //#endregion
6992
+ //#region src/tasks/poller.ts
6993
+ /**
6994
+ * poller.ts — one scheduler tick
6995
+ *
6996
+ * Reads Todoist, decides, acts, reports. Run by launchd on an interval; carries
6997
+ * no LLM, so a tick that finds nothing to do costs one API call and no tokens.
6998
+ *
6999
+ * Run state (start times, probe counts) is deliberately local and transient.
7000
+ * Losing it costs one extra probe, not a wrong decision — `decide` returns
7001
+ * "orphaned" rather than assuming a task is dead. The durable state — schedule,
7002
+ * order, running flag, learned durations — all lives in Todoist, so a new
7003
+ * machine picks up where this one left off.
7004
+ */
7005
+ const STATE_FILE$1 = join(homedir(), ".pai", "scheduler-state.json");
7006
+ /** How many past durations to keep per task. */
7007
+ const HISTORY_LIMIT = 5;
7008
+ const EMPTY = {
7009
+ ...EMPTY_RUN_STATE,
7010
+ history: {},
7011
+ lastReported: {}
7012
+ };
7013
+ /**
7014
+ * Run state is a rebuildable cache, so a damaged file must not block the
7015
+ * scheduler forever — starting fresh is the correct recovery here, which is
7016
+ * exactly the case json-store's guard is NOT for.
7017
+ */
7018
+ function loadState$1() {
7019
+ try {
7020
+ const raw = readJsonStrict(STATE_FILE$1, "~/.pai/scheduler-state.json");
7021
+ return {
7022
+ ...EMPTY,
7023
+ ...raw
7024
+ };
7025
+ } catch {
7026
+ return { ...EMPTY };
7027
+ }
7028
+ }
7029
+ function saveState$1(state) {
7030
+ writeJsonAtomic(STATE_FILE$1, state, { backup: false });
7031
+ }
7032
+ /**
7033
+ * Multiple of the expected duration past which a task is flagged even though it
7034
+ * keeps reporting `busy`. A session can be alive and still wrong — stuck in a
7035
+ * loop, waiting on a prompt that will never come. Liveness is not progress.
7036
+ */
7037
+ const GROSS_OVERRUN_FACTOR = 5;
7038
+ async function tick(opts) {
7039
+ const now = opts.now ?? Date.now();
7040
+ const state = loadState$1();
7041
+ const report = {
7042
+ decisions: [],
7043
+ dispatched: 0,
7044
+ completed: 0,
7045
+ stuck: 0,
7046
+ probed: 0
7047
+ };
7048
+ const ordered = dispatchOrder(await opts.provider.listOpen({ includeUnrouted: true }));
7049
+ for (const task of ordered) {
7050
+ const d = decide(task, {
7051
+ now,
7052
+ state,
7053
+ history: state.history
7054
+ });
7055
+ let note = "";
7056
+ switch (d.action) {
7057
+ case "wait": continue;
7058
+ case "skip":
7059
+ note = d.reason;
7060
+ break;
7061
+ case "running":
7062
+ note = `${d.elapsedMinutes}m elapsed`;
7063
+ break;
7064
+ case "dispatch":
7065
+ note = await handleDispatch(task, d.overdueMinutes, opts, state, now);
7066
+ if (!opts.dryRun) report.dispatched++;
7067
+ break;
7068
+ case "complete":
7069
+ note = await handleComplete(task, d.durationMinutes, opts, state);
7070
+ if (!opts.dryRun) report.completed++;
7071
+ break;
7072
+ case "probe":
7073
+ case "orphaned": {
7074
+ const result = await handleProbe(task, d.action === "probe" ? d.elapsedMinutes : null, opts, state);
7075
+ note = result.note;
7076
+ if (!opts.dryRun) {
7077
+ report.probed++;
7078
+ if (result.stuck) report.stuck++;
7079
+ }
7080
+ break;
7081
+ }
7082
+ }
7083
+ report.decisions.push({
7084
+ decision: d,
7085
+ note
7086
+ });
7087
+ }
7088
+ if (!opts.dryRun) saveState$1(state);
7089
+ return report;
7090
+ }
7091
+ async function handleDispatch(task, overdue, opts, state, now) {
7092
+ const late = overdue > 5 ? ` (${overdue}m late)` : "";
7093
+ if (opts.dryRun) return `would dispatch to ${task.owner.project ?? "nobody"}${late}`;
7094
+ if (!task.owner.project) return "unrouted — cannot dispatch";
7095
+ const result = await dispatchTask(task, {
7096
+ transport: opts.transport,
7097
+ autoDispatch: opts.autoDispatch,
7098
+ spawnIfAbsent: true
7099
+ });
7100
+ if (result.outcome === "delivered" || result.outcome === "spawned") {
7101
+ await opts.provider.setLabels(task.id, [...task.labels, RUNNING_LABEL]);
7102
+ state.startedAt[task.id] = now;
7103
+ delete state.failedProbes[task.id];
7104
+ return `${result.outcome} to ${result.session}${late}`;
7105
+ }
7106
+ return `not dispatched: ${result.outcome}${result.reason ? " — " + result.reason : ""}`;
7107
+ }
7108
+ async function handleComplete(task, durationMinutes, opts, state) {
7109
+ if (opts.dryRun) return `would clear ${RUNNING_LABEL}, ${durationMinutes ?? "?"}m`;
7110
+ await opts.provider.setLabels(task.id, task.labels.filter((l) => l.toLowerCase() !== RUNNING_LABEL));
7111
+ const wasStuck = (state.failedProbes[task.id] ?? 0) > 0;
7112
+ delete state.startedAt[task.id];
7113
+ delete state.failedProbes[task.id];
7114
+ if (durationMinutes !== null && !wasStuck) {
7115
+ const hist = state.history[task.id] ?? [];
7116
+ hist.push(durationMinutes);
7117
+ state.history[task.id] = hist.slice(-HISTORY_LIMIT);
7118
+ }
7119
+ return durationMinutes === null ? "completed (duration unknown)" : `completed in ${durationMinutes}m${wasStuck ? " — not recorded, run was probed" : ""}`;
7120
+ }
7121
+ async function handleProbe(task, elapsed, opts, state) {
7122
+ const project = task.owner.project;
7123
+ const el = elapsed === null ? "unknown" : `${elapsed}m`;
7124
+ if (opts.dryRun) return {
7125
+ note: `would probe ${project ?? "?"} (${el} elapsed)`,
7126
+ stuck: false
7127
+ };
7128
+ if (!opts.prober || !project) return {
7129
+ note: `overrun (${el}) — no liveness probe available, leaving alone`,
7130
+ stuck: false
7131
+ };
7132
+ const answer = await opts.prober.ask(project, `Are you still working on the task "${task.title}"? Reply in one short line.`);
7133
+ if (answer.state === "replied" || answer.state === "busy") {
7134
+ state.failedProbes[task.id] = 0;
7135
+ const expected = expectedMinutes(state.history[task.id] ?? []);
7136
+ if (elapsed !== null && elapsed > expected * GROSS_OVERRUN_FACTOR) return {
7137
+ note: `alive but ${el} against an expected ${expected}m — ${GROSS_OVERRUN_FACTOR}x over, worth a look`,
7138
+ stuck: true
7139
+ };
7140
+ return answer.state === "busy" ? {
7141
+ note: `busy after ${el} (mid-turn, nothing sent)`,
7142
+ stuck: false
7143
+ } : {
7144
+ note: `alive after ${el}: ${answer.reply ?? "(no detail)"}`,
7145
+ stuck: false
7146
+ };
7147
+ }
7148
+ const fails = (state.failedProbes[task.id] ?? 0) + 1;
7149
+ state.failedProbes[task.id] = fails;
7150
+ const what = answer.state === "absent" ? "session gone" : "no reply";
7151
+ if (fails >= STUCK_AFTER_FAILED_PROBES) return {
7152
+ note: `STUCK after ${el} — ${what} x${fails} (${answer.reason ?? "no detail"})`,
7153
+ stuck: true
7154
+ };
7155
+ return {
7156
+ note: `${what} ${fails}/${STUCK_AFTER_FAILED_PROBES} after ${el}`,
7157
+ stuck: false
7158
+ };
7159
+ }
7160
+
7161
+ //#endregion
7162
+ //#region src/tasks/schedule-install.ts
7163
+ /**
7164
+ * schedule-install.ts — install the scheduler tick as a launchd agent
7165
+ *
7166
+ * Two speeds were considered and rejected in favour of one: a fixed interval.
7167
+ * A 15-minute tick is 96 runs a day, each one API call and zero tokens, and
7168
+ * daily routines do not need better than 15-minute granularity — a 09:00 sweep
7169
+ * starting at 09:12 is fine. Adaptive intervals would add a second thing that
7170
+ * can silently stop.
7171
+ *
7172
+ * StartInterval rather than StartCalendarInterval on purpose: the schedule
7173
+ * lives in Todoist, not here. This agent only decides how often to *look*.
7174
+ * That means one plist total, however many routines exist, and rescheduling a
7175
+ * routine never touches the machine.
7176
+ */
7177
+ const SCHEDULE_LABEL = "com.pai.task-scheduler";
7178
+ const LAUNCH_AGENTS = join(homedir(), "Library", "LaunchAgents");
7179
+ const SCHEDULE_PLIST = join(LAUNCH_AGENTS, `${SCHEDULE_LABEL}.plist`);
7180
+ const SCHEDULE_LOG = "/tmp/pai-scheduler.log";
7181
+ /** Default tick, in seconds. */
7182
+ const DEFAULT_INTERVAL_SECS = 900;
7183
+ function cliPath() {
7184
+ return fileURLToPath(new URL("index.mjs", import.meta.url));
7185
+ }
7186
+ function generateSchedulePlist(intervalSecs, cli) {
7187
+ return `<?xml version="1.0" encoding="UTF-8"?>
7188
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
7189
+ <plist version="1.0">
7190
+ <dict>
7191
+ <key>Label</key>
7192
+ <string>${SCHEDULE_LABEL}</string>
7193
+
7194
+ <key>ProgramArguments</key>
7195
+ <array>
7196
+ <string>/usr/local/bin/node</string>
7197
+ <string>${cli}</string>
7198
+ <string>task</string>
7199
+ <string>poll</string>
7200
+ </array>
7201
+
7202
+ <key>StartInterval</key>
7203
+ <integer>${intervalSecs}</integer>
7204
+
7205
+ <key>RunAtLoad</key>
7206
+ <true/>
7207
+
7208
+ <key>StandardOutPath</key>
7209
+ <string>${SCHEDULE_LOG}</string>
7210
+
7211
+ <key>StandardErrorPath</key>
7212
+ <string>${SCHEDULE_LOG}</string>
7213
+
7214
+ <key>EnvironmentVariables</key>
7215
+ <dict>
7216
+ <key>PATH</key>
7217
+ <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
7218
+ </dict>
7219
+ </dict>
7220
+ </plist>
7221
+ `;
7222
+ }
7223
+ function installSchedule(intervalSecs = DEFAULT_INTERVAL_SECS) {
7224
+ if (!existsSync(LAUNCH_AGENTS)) mkdirSync(LAUNCH_AGENTS, { recursive: true });
7225
+ writeFileSync(SCHEDULE_PLIST, generateSchedulePlist(intervalSecs, cliPath()), "utf8");
7226
+ spawnSync("launchctl", ["unload", SCHEDULE_PLIST], { encoding: "utf8" });
7227
+ const load = spawnSync("launchctl", ["load", SCHEDULE_PLIST], { encoding: "utf8" });
7228
+ return {
7229
+ plistPath: SCHEDULE_PLIST,
7230
+ intervalSecs,
7231
+ loaded: load.status === 0,
7232
+ message: load.status === 0 ? `Scheduler installed — ticking every ${Math.round(intervalSecs / 60)} min.` : `Plist written but launchctl load failed: ${(load.stderr || "").trim()}`
7233
+ };
7234
+ }
7235
+ function uninstallSchedule() {
7236
+ if (!existsSync(SCHEDULE_PLIST)) return "Scheduler is not installed.";
7237
+ spawnSync("launchctl", ["unload", SCHEDULE_PLIST], { encoding: "utf8" });
7238
+ unlinkSync(SCHEDULE_PLIST);
7239
+ return "Scheduler uninstalled.";
7240
+ }
7241
+ function scheduleStatus() {
7242
+ if (!existsSync(SCHEDULE_PLIST)) return {
7243
+ installed: false,
7244
+ running: false,
7245
+ detail: "Not installed."
7246
+ };
7247
+ const running = spawnSync("launchctl", ["list", SCHEDULE_LABEL], { encoding: "utf8" }).status === 0;
7248
+ return {
7249
+ installed: true,
7250
+ running,
7251
+ detail: running ? `Loaded. Log: ${SCHEDULE_LOG}` : "Plist present but not loaded — run `pai task schedule install` again."
7252
+ };
7253
+ }
7254
+
6757
7255
  //#endregion
6758
7256
  //#region src/cli/commands/task.ts
6759
7257
  const dim = chalk.dim;
@@ -6900,6 +7398,54 @@ function registerTaskCommands(taskCmd) {
6900
7398
  spawnIfAbsent: opts.spawn !== false
6901
7399
  }));
6902
7400
  });
7401
+ taskCmd.command("poll").description("One scheduler tick: dispatch what is due, check what is running, report").option("--dry-run", "Show what would happen without touching anything").action(async (opts) => {
7402
+ const provider = buildProvider();
7403
+ if (!provider) return reportUnconfigured();
7404
+ const config = loadConfig();
7405
+ const report = await tick({
7406
+ provider,
7407
+ transport: opts.dryRun ? null : await detectAiBroker(void 0, config.tasks?.dispatchTimeoutSecs),
7408
+ prober: opts.dryRun ? null : await detectProber(),
7409
+ autoDispatch: config.tasks?.autoDispatch ?? false,
7410
+ dryRun: Boolean(opts.dryRun)
7411
+ });
7412
+ if (report.decisions.length === 0) {
7413
+ console.log(dim(" Nothing due, nothing running."));
7414
+ return;
7415
+ }
7416
+ const mark = {
7417
+ dispatch: chalk.green("→"),
7418
+ complete: chalk.green("✓"),
7419
+ running: dim("·"),
7420
+ probe: chalk.yellow("?"),
7421
+ orphaned: chalk.yellow("!"),
7422
+ skip: dim("–"),
7423
+ wait: dim(" ")
7424
+ };
7425
+ console.log();
7426
+ for (const { decision, note } of report.decisions) {
7427
+ console.log(` ${mark[decision.action] ?? " "} ${decision.task.title}`);
7428
+ if (note) console.log(` ${dim(note)}`);
7429
+ }
7430
+ console.log();
7431
+ console.log(dim(` ${report.dispatched} dispatched, ${report.completed} completed, ${report.probed} probed, ${report.stuck} stuck`));
7432
+ console.log();
7433
+ });
7434
+ const scheduleCmd = taskCmd.command("schedule").description("Install, remove or inspect the launchd agent that ticks the scheduler");
7435
+ scheduleCmd.command("install").description("Install the scheduler tick (default: every 15 minutes)").option("--interval <secs>", "Seconds between ticks", (v) => Number.parseInt(v, 10)).action((opts) => {
7436
+ const r = installSchedule(opts.interval || DEFAULT_INTERVAL_SECS);
7437
+ console.log(r.loaded ? chalk.green(` ${r.message}`) : chalk.yellow(` ${r.message}`));
7438
+ console.log(dim(` ${r.plistPath}`));
7439
+ console.log(dim(` The schedule itself lives in Todoist — this only sets how often PAI looks.`));
7440
+ });
7441
+ scheduleCmd.command("uninstall").description("Remove the scheduler agent").action(() => console.log(chalk.green(` ${uninstallSchedule()}`)));
7442
+ scheduleCmd.command("status").description("Show whether the scheduler agent is installed and loaded").action(() => {
7443
+ const s = scheduleStatus();
7444
+ console.log();
7445
+ console.log(` ${s.installed ? chalk.green("installed") : dim("not installed")} ${s.installed ? s.running ? chalk.green("· loaded") : chalk.yellow("· not loaded") : ""}`);
7446
+ console.log(dim(` ${s.detail}`));
7447
+ console.log();
7448
+ });
6903
7449
  taskCmd.command("config").description("View or change task bus settings without running the full setup wizard").option("--token", "Prompt for the Todoist API token (input is hidden)").option("--from-env", "Adopt the token from TODOIST_API_KEY in the environment").option("--project <id>", "Tracker project ID that roots the bus (an ID, never a name)").option("--findings <id>", "Section ID for the findings inbox").option("--timeout <secs>", "Seconds a single dispatch may take", (v) => Number.parseInt(v, 10)).option("--auto-dispatch <bool>", "Hand tasks to owning sessions automatically (true/false)").option("--disable", "Turn the task bus off without discarding its settings").action(async (opts) => {
6904
7450
  const raw = readConfigRaw();
6905
7451
  const tasks = raw.tasks ?? {};
@@ -9133,4 +9679,4 @@ async function cmdPick(db, opts = {}) {
9133
9679
 
9134
9680
  //#endregion
9135
9681
  export { registerDaemonCommands as C, registerProjectsCommands as D, registerRegistryCommands as E, findMovedPath as O, registerBackupCommands as S, registerMemoryCommands as T, registerObservationCommands as _, cmdPauseAll as a, registerSetupCommand as b, cmdPause as c, registerKgCommands as d, registerTopicCommands as f, registerSkillCommands as g, registerUpdateCommand as h, cmdClearNames as i, resolveIdentifier as k, registerHelpCommand as l, registerNotifyCommands as m, cmdFind as n, cmdGoto as o, registerTaskCommands as p, cmdList as r, cmdEnd as s, cmdPick as t, registerDbCommands as u, registerZettelCommands as v, registerMcpCommands as w, registerRestoreCommands as x, registerObsidianCommands as y };
9136
- //# sourceMappingURL=pick-CsHZ8Abv.mjs.map
9682
+ //# sourceMappingURL=pick-BPlB38eB.mjs.map