@tekmidian/pai 0.13.5 → 0.14.0
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.mjs +1 -1
- package/dist/cli/program.mjs +1 -1
- package/dist/{pick-CsHZ8Abv.mjs → pick-DxS6p2vI.mjs} +529 -1
- package/dist/pick-DxS6p2vI.mjs.map +1 -0
- package/docs/commands/README.md +5 -0
- package/docs/commands/task.md +39 -0
- package/package.json +1 -1
- package/dist/pick-CsHZ8Abv.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -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-
|
|
9
|
+
import "../pick-DxS6p2vI.mjs";
|
|
10
10
|
import "../kg-extraction-C8DEUHTS.mjs";
|
|
11
11
|
import "../factory-Q88X1bAN.mjs";
|
|
12
12
|
import "../config-C8m-tPhP.mjs";
|
package/dist/cli/program.mjs
CHANGED
|
@@ -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-
|
|
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-DxS6p2vI.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,77 @@ 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
|
+
replied: false,
|
|
6790
|
+
reason: e instanceof Error ? e.message : String(e)
|
|
6791
|
+
};
|
|
6792
|
+
}
|
|
6793
|
+
const start = stdout.lastIndexOf("{");
|
|
6794
|
+
if (start === -1) return {
|
|
6795
|
+
replied: false,
|
|
6796
|
+
reason: "aibroker returned no JSON"
|
|
6797
|
+
};
|
|
6798
|
+
try {
|
|
6799
|
+
const wire = JSON.parse(stdout.slice(start));
|
|
6800
|
+
return wire.replied === true ? {
|
|
6801
|
+
replied: true,
|
|
6802
|
+
reply: wire.reply
|
|
6803
|
+
} : {
|
|
6804
|
+
replied: false,
|
|
6805
|
+
reason: wire.reason ?? "no reply"
|
|
6806
|
+
};
|
|
6807
|
+
} catch {
|
|
6808
|
+
return {
|
|
6809
|
+
replied: false,
|
|
6810
|
+
reason: "aibroker returned malformed JSON"
|
|
6811
|
+
};
|
|
6812
|
+
}
|
|
6813
|
+
}
|
|
6814
|
+
};
|
|
6815
|
+
/**
|
|
6816
|
+
* Return a prober if the installed `aibroker` supports `ask`.
|
|
6817
|
+
*
|
|
6818
|
+
* Probes for the subcommand rather than the binary: an older AIBroker would
|
|
6819
|
+
* otherwise fail once per stuck task instead of the scheduler simply knowing it
|
|
6820
|
+
* has no liveness check and saying so.
|
|
6821
|
+
*/
|
|
6822
|
+
async function detectProber(bin = "aibroker") {
|
|
6823
|
+
try {
|
|
6824
|
+
const help = await new Promise((resolve, reject) => {
|
|
6825
|
+
execFile(bin, ["help"], { timeout: 1e4 }, (error, stdout, stderr) => {
|
|
6826
|
+
if (error && !stdout) reject(error);
|
|
6827
|
+
else resolve(stdout + stderr);
|
|
6828
|
+
});
|
|
6829
|
+
});
|
|
6830
|
+
return /\bask\b/.test(help) ? new AiBrokerProber(bin) : null;
|
|
6831
|
+
} catch {
|
|
6832
|
+
return null;
|
|
6833
|
+
}
|
|
6834
|
+
}
|
|
6835
|
+
/**
|
|
6738
6836
|
* Return a transport if the `aibroker` CLI is present and supports dispatch.
|
|
6739
6837
|
*
|
|
6740
6838
|
* Probing for the subcommand rather than just the binary matters: AIBroker
|
|
@@ -6754,6 +6852,388 @@ async function detectAiBroker(bin = "aibroker", timeoutSecs = DEFAULT_DISPATCH_T
|
|
|
6754
6852
|
}
|
|
6755
6853
|
}
|
|
6756
6854
|
|
|
6855
|
+
//#endregion
|
|
6856
|
+
//#region src/tasks/scheduler.ts
|
|
6857
|
+
/** Set when dispatched, cleared when the due date advances. */
|
|
6858
|
+
const RUNNING_LABEL = "pai-running";
|
|
6859
|
+
/** `pai-skip-if-late:4h` — past that, the window is gone; wait for the next. */
|
|
6860
|
+
const SKIP_IF_LATE_RE = /^pai-skip-if-late:(\d+)([hm])$/i;
|
|
6861
|
+
/** Fallback when a task has no learned duration yet. */
|
|
6862
|
+
const DEFAULT_EXPECTED_MINUTES = 30;
|
|
6863
|
+
/** Probe once the run exceeds expected x this. */
|
|
6864
|
+
const PROBE_FACTOR = 1.5;
|
|
6865
|
+
/** Consecutive unanswered probes before a run counts as stuck. */
|
|
6866
|
+
const STUCK_AFTER_FAILED_PROBES = 3;
|
|
6867
|
+
const EMPTY_RUN_STATE = {
|
|
6868
|
+
startedAt: {},
|
|
6869
|
+
failedProbes: {}
|
|
6870
|
+
};
|
|
6871
|
+
function isRunning(task) {
|
|
6872
|
+
return task.labels.some((l) => l.toLowerCase() === RUNNING_LABEL);
|
|
6873
|
+
}
|
|
6874
|
+
/** Minutes after which a missed window is abandoned, or null for "always run". */
|
|
6875
|
+
function skipIfLateMinutes(task) {
|
|
6876
|
+
for (const label of task.labels) {
|
|
6877
|
+
const m = SKIP_IF_LATE_RE.exec(label.trim());
|
|
6878
|
+
if (m) {
|
|
6879
|
+
const n = Number.parseInt(m[1], 10);
|
|
6880
|
+
return m[2].toLowerCase() === "h" ? n * 60 : n;
|
|
6881
|
+
}
|
|
6882
|
+
}
|
|
6883
|
+
return null;
|
|
6884
|
+
}
|
|
6885
|
+
/**
|
|
6886
|
+
* How overdue a task is, in minutes. Negative means still in the future.
|
|
6887
|
+
*
|
|
6888
|
+
* A date-only due ("2026-08-01") is treated as due at the start of that day,
|
|
6889
|
+
* which is what Todoist shows the user.
|
|
6890
|
+
*/
|
|
6891
|
+
function overdueMinutes(task, now) {
|
|
6892
|
+
if (!task.due) return Number.NEGATIVE_INFINITY;
|
|
6893
|
+
const due = task.due.length <= 10 ? `${task.due}T00:00:00` : task.due;
|
|
6894
|
+
const dueMs = new Date(due).getTime();
|
|
6895
|
+
if (Number.isNaN(dueMs)) return Number.NEGATIVE_INFINITY;
|
|
6896
|
+
return Math.floor((now - dueMs) / 6e4);
|
|
6897
|
+
}
|
|
6898
|
+
/**
|
|
6899
|
+
* Expected runtime for a task.
|
|
6900
|
+
*
|
|
6901
|
+
* The mean rather than the max, deliberately: with a liveness probe available,
|
|
6902
|
+
* guessing low costs one extra probe, while guessing high leaves a dead session
|
|
6903
|
+
* undetected for far longer. The probe is what makes a tight estimate safe.
|
|
6904
|
+
*
|
|
6905
|
+
* Runs that ended in stuck or re-dispatch must never be fed in here — one hung
|
|
6906
|
+
* run would inflate the mean and every later run would inherit a too-generous
|
|
6907
|
+
* threshold.
|
|
6908
|
+
*/
|
|
6909
|
+
function expectedMinutes(observed, fallback = DEFAULT_EXPECTED_MINUTES) {
|
|
6910
|
+
const clean = observed.filter((n) => Number.isFinite(n) && n > 0);
|
|
6911
|
+
if (clean.length === 0) return fallback;
|
|
6912
|
+
const recent = clean.slice(-5);
|
|
6913
|
+
return Math.max(1, Math.round(recent.reduce((a, b) => a + b, 0) / recent.length));
|
|
6914
|
+
}
|
|
6915
|
+
/**
|
|
6916
|
+
* Decide what should happen to one task on this tick.
|
|
6917
|
+
*
|
|
6918
|
+
* Pure: no I/O, no clock reads. Everything comes in via options so the state
|
|
6919
|
+
* machine can be tested against a fixed clock rather than by waiting.
|
|
6920
|
+
*/
|
|
6921
|
+
function decide(task, opts) {
|
|
6922
|
+
const { now, state } = opts;
|
|
6923
|
+
const running = isRunning(task);
|
|
6924
|
+
const overdue = overdueMinutes(task, now);
|
|
6925
|
+
const startedAt = state.startedAt[task.id];
|
|
6926
|
+
if (running) {
|
|
6927
|
+
if (overdue < 0) return {
|
|
6928
|
+
action: "complete",
|
|
6929
|
+
task,
|
|
6930
|
+
durationMinutes: startedAt ? Math.max(1, Math.round((now - startedAt) / 6e4)) : null
|
|
6931
|
+
};
|
|
6932
|
+
if (startedAt === void 0) return {
|
|
6933
|
+
action: "orphaned",
|
|
6934
|
+
task,
|
|
6935
|
+
reason: "marked running but no start time is known — probe before assuming anything"
|
|
6936
|
+
};
|
|
6937
|
+
const elapsed = Math.max(0, Math.round((now - startedAt) / 6e4));
|
|
6938
|
+
const expected = expectedMinutes(opts.history?.[task.id] ?? []);
|
|
6939
|
+
if (elapsed >= expected * PROBE_FACTOR) return {
|
|
6940
|
+
action: "probe",
|
|
6941
|
+
task,
|
|
6942
|
+
elapsedMinutes: elapsed,
|
|
6943
|
+
expectedMinutes: expected
|
|
6944
|
+
};
|
|
6945
|
+
return {
|
|
6946
|
+
action: "running",
|
|
6947
|
+
task,
|
|
6948
|
+
elapsedMinutes: elapsed
|
|
6949
|
+
};
|
|
6950
|
+
}
|
|
6951
|
+
if (overdue < 0) return {
|
|
6952
|
+
action: "wait",
|
|
6953
|
+
task
|
|
6954
|
+
};
|
|
6955
|
+
const limit = skipIfLateMinutes(task);
|
|
6956
|
+
if (limit !== null && overdue > limit) return {
|
|
6957
|
+
action: "skip",
|
|
6958
|
+
task,
|
|
6959
|
+
reason: `overdue by ${overdue}m, past its ${limit}m window — waiting for the next occurrence`
|
|
6960
|
+
};
|
|
6961
|
+
return {
|
|
6962
|
+
action: "dispatch",
|
|
6963
|
+
task,
|
|
6964
|
+
overdueMinutes: Math.max(0, overdue)
|
|
6965
|
+
};
|
|
6966
|
+
}
|
|
6967
|
+
/**
|
|
6968
|
+
* Order tasks for dispatch.
|
|
6969
|
+
*
|
|
6970
|
+
* Subtasks of the same parent run in Todoist's own order, so a routine is
|
|
6971
|
+
* sequenced by dragging its steps in the UI. Everything else falls back to
|
|
6972
|
+
* priority then due time, so the most overdue urgent thing goes first.
|
|
6973
|
+
*/
|
|
6974
|
+
function dispatchOrder(tasks) {
|
|
6975
|
+
const rank = {
|
|
6976
|
+
p1: 0,
|
|
6977
|
+
p2: 1,
|
|
6978
|
+
p3: 2,
|
|
6979
|
+
p4: 3
|
|
6980
|
+
};
|
|
6981
|
+
return [...tasks].sort((a, b) => {
|
|
6982
|
+
const p = rank[a.priority] - rank[b.priority];
|
|
6983
|
+
if (p !== 0) return p;
|
|
6984
|
+
return (a.due ?? "").localeCompare(b.due ?? "");
|
|
6985
|
+
});
|
|
6986
|
+
}
|
|
6987
|
+
|
|
6988
|
+
//#endregion
|
|
6989
|
+
//#region src/tasks/poller.ts
|
|
6990
|
+
/**
|
|
6991
|
+
* poller.ts — one scheduler tick
|
|
6992
|
+
*
|
|
6993
|
+
* Reads Todoist, decides, acts, reports. Run by launchd on an interval; carries
|
|
6994
|
+
* no LLM, so a tick that finds nothing to do costs one API call and no tokens.
|
|
6995
|
+
*
|
|
6996
|
+
* Run state (start times, probe counts) is deliberately local and transient.
|
|
6997
|
+
* Losing it costs one extra probe, not a wrong decision — `decide` returns
|
|
6998
|
+
* "orphaned" rather than assuming a task is dead. The durable state — schedule,
|
|
6999
|
+
* order, running flag, learned durations — all lives in Todoist, so a new
|
|
7000
|
+
* machine picks up where this one left off.
|
|
7001
|
+
*/
|
|
7002
|
+
const STATE_FILE$1 = join(homedir(), ".pai", "scheduler-state.json");
|
|
7003
|
+
/** How many past durations to keep per task. */
|
|
7004
|
+
const HISTORY_LIMIT = 5;
|
|
7005
|
+
const EMPTY = {
|
|
7006
|
+
...EMPTY_RUN_STATE,
|
|
7007
|
+
history: {},
|
|
7008
|
+
lastReported: {}
|
|
7009
|
+
};
|
|
7010
|
+
/**
|
|
7011
|
+
* Run state is a rebuildable cache, so a damaged file must not block the
|
|
7012
|
+
* scheduler forever — starting fresh is the correct recovery here, which is
|
|
7013
|
+
* exactly the case json-store's guard is NOT for.
|
|
7014
|
+
*/
|
|
7015
|
+
function loadState$1() {
|
|
7016
|
+
try {
|
|
7017
|
+
const raw = readJsonStrict(STATE_FILE$1, "~/.pai/scheduler-state.json");
|
|
7018
|
+
return {
|
|
7019
|
+
...EMPTY,
|
|
7020
|
+
...raw
|
|
7021
|
+
};
|
|
7022
|
+
} catch {
|
|
7023
|
+
return { ...EMPTY };
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
function saveState$1(state) {
|
|
7027
|
+
writeJsonAtomic(STATE_FILE$1, state, { backup: false });
|
|
7028
|
+
}
|
|
7029
|
+
async function tick(opts) {
|
|
7030
|
+
const now = opts.now ?? Date.now();
|
|
7031
|
+
const state = loadState$1();
|
|
7032
|
+
const report = {
|
|
7033
|
+
decisions: [],
|
|
7034
|
+
dispatched: 0,
|
|
7035
|
+
completed: 0,
|
|
7036
|
+
stuck: 0,
|
|
7037
|
+
probed: 0
|
|
7038
|
+
};
|
|
7039
|
+
const ordered = dispatchOrder(await opts.provider.listOpen({ includeUnrouted: true }));
|
|
7040
|
+
for (const task of ordered) {
|
|
7041
|
+
const d = decide(task, {
|
|
7042
|
+
now,
|
|
7043
|
+
state,
|
|
7044
|
+
history: state.history
|
|
7045
|
+
});
|
|
7046
|
+
let note = "";
|
|
7047
|
+
switch (d.action) {
|
|
7048
|
+
case "wait": continue;
|
|
7049
|
+
case "skip":
|
|
7050
|
+
note = d.reason;
|
|
7051
|
+
break;
|
|
7052
|
+
case "running":
|
|
7053
|
+
note = `${d.elapsedMinutes}m elapsed`;
|
|
7054
|
+
break;
|
|
7055
|
+
case "dispatch":
|
|
7056
|
+
note = await handleDispatch(task, d.overdueMinutes, opts, state, now);
|
|
7057
|
+
if (!opts.dryRun) report.dispatched++;
|
|
7058
|
+
break;
|
|
7059
|
+
case "complete":
|
|
7060
|
+
note = await handleComplete(task, d.durationMinutes, opts, state);
|
|
7061
|
+
if (!opts.dryRun) report.completed++;
|
|
7062
|
+
break;
|
|
7063
|
+
case "probe":
|
|
7064
|
+
case "orphaned": {
|
|
7065
|
+
const result = await handleProbe(task, d.action === "probe" ? d.elapsedMinutes : null, opts, state);
|
|
7066
|
+
note = result.note;
|
|
7067
|
+
if (!opts.dryRun) {
|
|
7068
|
+
report.probed++;
|
|
7069
|
+
if (result.stuck) report.stuck++;
|
|
7070
|
+
}
|
|
7071
|
+
break;
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
report.decisions.push({
|
|
7075
|
+
decision: d,
|
|
7076
|
+
note
|
|
7077
|
+
});
|
|
7078
|
+
}
|
|
7079
|
+
if (!opts.dryRun) saveState$1(state);
|
|
7080
|
+
return report;
|
|
7081
|
+
}
|
|
7082
|
+
async function handleDispatch(task, overdue, opts, state, now) {
|
|
7083
|
+
const late = overdue > 5 ? ` (${overdue}m late)` : "";
|
|
7084
|
+
if (opts.dryRun) return `would dispatch to ${task.owner.project ?? "nobody"}${late}`;
|
|
7085
|
+
if (!task.owner.project) return "unrouted — cannot dispatch";
|
|
7086
|
+
const result = await dispatchTask(task, {
|
|
7087
|
+
transport: opts.transport,
|
|
7088
|
+
autoDispatch: opts.autoDispatch,
|
|
7089
|
+
spawnIfAbsent: true
|
|
7090
|
+
});
|
|
7091
|
+
if (result.outcome === "delivered" || result.outcome === "spawned") {
|
|
7092
|
+
await opts.provider.setLabels(task.id, [...task.labels, RUNNING_LABEL]);
|
|
7093
|
+
state.startedAt[task.id] = now;
|
|
7094
|
+
delete state.failedProbes[task.id];
|
|
7095
|
+
return `${result.outcome} to ${result.session}${late}`;
|
|
7096
|
+
}
|
|
7097
|
+
return `not dispatched: ${result.outcome}${result.reason ? " — " + result.reason : ""}`;
|
|
7098
|
+
}
|
|
7099
|
+
async function handleComplete(task, durationMinutes, opts, state) {
|
|
7100
|
+
if (opts.dryRun) return `would clear ${RUNNING_LABEL}, ${durationMinutes ?? "?"}m`;
|
|
7101
|
+
await opts.provider.setLabels(task.id, task.labels.filter((l) => l.toLowerCase() !== RUNNING_LABEL));
|
|
7102
|
+
const wasStuck = (state.failedProbes[task.id] ?? 0) > 0;
|
|
7103
|
+
delete state.startedAt[task.id];
|
|
7104
|
+
delete state.failedProbes[task.id];
|
|
7105
|
+
if (durationMinutes !== null && !wasStuck) {
|
|
7106
|
+
const hist = state.history[task.id] ?? [];
|
|
7107
|
+
hist.push(durationMinutes);
|
|
7108
|
+
state.history[task.id] = hist.slice(-HISTORY_LIMIT);
|
|
7109
|
+
}
|
|
7110
|
+
return durationMinutes === null ? "completed (duration unknown)" : `completed in ${durationMinutes}m${wasStuck ? " — not recorded, run was probed" : ""}`;
|
|
7111
|
+
}
|
|
7112
|
+
async function handleProbe(task, elapsed, opts, state) {
|
|
7113
|
+
const project = task.owner.project;
|
|
7114
|
+
const el = elapsed === null ? "unknown" : `${elapsed}m`;
|
|
7115
|
+
if (opts.dryRun) return {
|
|
7116
|
+
note: `would probe ${project ?? "?"} (${el} elapsed)`,
|
|
7117
|
+
stuck: false
|
|
7118
|
+
};
|
|
7119
|
+
if (!opts.prober || !project) return {
|
|
7120
|
+
note: `overrun (${el}) — no liveness probe available, leaving alone`,
|
|
7121
|
+
stuck: false
|
|
7122
|
+
};
|
|
7123
|
+
const answer = await opts.prober.ask(project, `Are you still working on the task "${task.title}"? Reply in one short line.`);
|
|
7124
|
+
if (answer.replied) {
|
|
7125
|
+
state.failedProbes[task.id] = 0;
|
|
7126
|
+
return {
|
|
7127
|
+
note: `alive after ${el}: ${answer.reply ?? "(no detail)"}`,
|
|
7128
|
+
stuck: false
|
|
7129
|
+
};
|
|
7130
|
+
}
|
|
7131
|
+
const fails = (state.failedProbes[task.id] ?? 0) + 1;
|
|
7132
|
+
state.failedProbes[task.id] = fails;
|
|
7133
|
+
if (fails >= STUCK_AFTER_FAILED_PROBES) return {
|
|
7134
|
+
note: `STUCK after ${el} and ${fails} unanswered probes (${answer.reason ?? "no reply"}) — needs attention`,
|
|
7135
|
+
stuck: true
|
|
7136
|
+
};
|
|
7137
|
+
return {
|
|
7138
|
+
note: `no reply ${fails}/${STUCK_AFTER_FAILED_PROBES} after ${el}`,
|
|
7139
|
+
stuck: false
|
|
7140
|
+
};
|
|
7141
|
+
}
|
|
7142
|
+
|
|
7143
|
+
//#endregion
|
|
7144
|
+
//#region src/tasks/schedule-install.ts
|
|
7145
|
+
/**
|
|
7146
|
+
* schedule-install.ts — install the scheduler tick as a launchd agent
|
|
7147
|
+
*
|
|
7148
|
+
* Two speeds were considered and rejected in favour of one: a fixed interval.
|
|
7149
|
+
* A 15-minute tick is 96 runs a day, each one API call and zero tokens, and
|
|
7150
|
+
* daily routines do not need better than 15-minute granularity — a 09:00 sweep
|
|
7151
|
+
* starting at 09:12 is fine. Adaptive intervals would add a second thing that
|
|
7152
|
+
* can silently stop.
|
|
7153
|
+
*
|
|
7154
|
+
* StartInterval rather than StartCalendarInterval on purpose: the schedule
|
|
7155
|
+
* lives in Todoist, not here. This agent only decides how often to *look*.
|
|
7156
|
+
* That means one plist total, however many routines exist, and rescheduling a
|
|
7157
|
+
* routine never touches the machine.
|
|
7158
|
+
*/
|
|
7159
|
+
const SCHEDULE_LABEL = "com.pai.task-scheduler";
|
|
7160
|
+
const LAUNCH_AGENTS = join(homedir(), "Library", "LaunchAgents");
|
|
7161
|
+
const SCHEDULE_PLIST = join(LAUNCH_AGENTS, `${SCHEDULE_LABEL}.plist`);
|
|
7162
|
+
const SCHEDULE_LOG = "/tmp/pai-scheduler.log";
|
|
7163
|
+
/** Default tick, in seconds. */
|
|
7164
|
+
const DEFAULT_INTERVAL_SECS = 900;
|
|
7165
|
+
function cliPath() {
|
|
7166
|
+
return fileURLToPath(new URL("index.mjs", import.meta.url));
|
|
7167
|
+
}
|
|
7168
|
+
function generateSchedulePlist(intervalSecs, cli) {
|
|
7169
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
7170
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
7171
|
+
<plist version="1.0">
|
|
7172
|
+
<dict>
|
|
7173
|
+
<key>Label</key>
|
|
7174
|
+
<string>${SCHEDULE_LABEL}</string>
|
|
7175
|
+
|
|
7176
|
+
<key>ProgramArguments</key>
|
|
7177
|
+
<array>
|
|
7178
|
+
<string>/usr/local/bin/node</string>
|
|
7179
|
+
<string>${cli}</string>
|
|
7180
|
+
<string>task</string>
|
|
7181
|
+
<string>poll</string>
|
|
7182
|
+
</array>
|
|
7183
|
+
|
|
7184
|
+
<key>StartInterval</key>
|
|
7185
|
+
<integer>${intervalSecs}</integer>
|
|
7186
|
+
|
|
7187
|
+
<key>RunAtLoad</key>
|
|
7188
|
+
<true/>
|
|
7189
|
+
|
|
7190
|
+
<key>StandardOutPath</key>
|
|
7191
|
+
<string>${SCHEDULE_LOG}</string>
|
|
7192
|
+
|
|
7193
|
+
<key>StandardErrorPath</key>
|
|
7194
|
+
<string>${SCHEDULE_LOG}</string>
|
|
7195
|
+
|
|
7196
|
+
<key>EnvironmentVariables</key>
|
|
7197
|
+
<dict>
|
|
7198
|
+
<key>PATH</key>
|
|
7199
|
+
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
7200
|
+
</dict>
|
|
7201
|
+
</dict>
|
|
7202
|
+
</plist>
|
|
7203
|
+
`;
|
|
7204
|
+
}
|
|
7205
|
+
function installSchedule(intervalSecs = DEFAULT_INTERVAL_SECS) {
|
|
7206
|
+
if (!existsSync(LAUNCH_AGENTS)) mkdirSync(LAUNCH_AGENTS, { recursive: true });
|
|
7207
|
+
writeFileSync(SCHEDULE_PLIST, generateSchedulePlist(intervalSecs, cliPath()), "utf8");
|
|
7208
|
+
spawnSync("launchctl", ["unload", SCHEDULE_PLIST], { encoding: "utf8" });
|
|
7209
|
+
const load = spawnSync("launchctl", ["load", SCHEDULE_PLIST], { encoding: "utf8" });
|
|
7210
|
+
return {
|
|
7211
|
+
plistPath: SCHEDULE_PLIST,
|
|
7212
|
+
intervalSecs,
|
|
7213
|
+
loaded: load.status === 0,
|
|
7214
|
+
message: load.status === 0 ? `Scheduler installed — ticking every ${Math.round(intervalSecs / 60)} min.` : `Plist written but launchctl load failed: ${(load.stderr || "").trim()}`
|
|
7215
|
+
};
|
|
7216
|
+
}
|
|
7217
|
+
function uninstallSchedule() {
|
|
7218
|
+
if (!existsSync(SCHEDULE_PLIST)) return "Scheduler is not installed.";
|
|
7219
|
+
spawnSync("launchctl", ["unload", SCHEDULE_PLIST], { encoding: "utf8" });
|
|
7220
|
+
unlinkSync(SCHEDULE_PLIST);
|
|
7221
|
+
return "Scheduler uninstalled.";
|
|
7222
|
+
}
|
|
7223
|
+
function scheduleStatus() {
|
|
7224
|
+
if (!existsSync(SCHEDULE_PLIST)) return {
|
|
7225
|
+
installed: false,
|
|
7226
|
+
running: false,
|
|
7227
|
+
detail: "Not installed."
|
|
7228
|
+
};
|
|
7229
|
+
const running = spawnSync("launchctl", ["list", SCHEDULE_LABEL], { encoding: "utf8" }).status === 0;
|
|
7230
|
+
return {
|
|
7231
|
+
installed: true,
|
|
7232
|
+
running,
|
|
7233
|
+
detail: running ? `Loaded. Log: ${SCHEDULE_LOG}` : "Plist present but not loaded — run `pai task schedule install` again."
|
|
7234
|
+
};
|
|
7235
|
+
}
|
|
7236
|
+
|
|
6757
7237
|
//#endregion
|
|
6758
7238
|
//#region src/cli/commands/task.ts
|
|
6759
7239
|
const dim = chalk.dim;
|
|
@@ -6900,6 +7380,54 @@ function registerTaskCommands(taskCmd) {
|
|
|
6900
7380
|
spawnIfAbsent: opts.spawn !== false
|
|
6901
7381
|
}));
|
|
6902
7382
|
});
|
|
7383
|
+
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) => {
|
|
7384
|
+
const provider = buildProvider();
|
|
7385
|
+
if (!provider) return reportUnconfigured();
|
|
7386
|
+
const config = loadConfig();
|
|
7387
|
+
const report = await tick({
|
|
7388
|
+
provider,
|
|
7389
|
+
transport: opts.dryRun ? null : await detectAiBroker(void 0, config.tasks?.dispatchTimeoutSecs),
|
|
7390
|
+
prober: opts.dryRun ? null : await detectProber(),
|
|
7391
|
+
autoDispatch: config.tasks?.autoDispatch ?? false,
|
|
7392
|
+
dryRun: Boolean(opts.dryRun)
|
|
7393
|
+
});
|
|
7394
|
+
if (report.decisions.length === 0) {
|
|
7395
|
+
console.log(dim(" Nothing due, nothing running."));
|
|
7396
|
+
return;
|
|
7397
|
+
}
|
|
7398
|
+
const mark = {
|
|
7399
|
+
dispatch: chalk.green("→"),
|
|
7400
|
+
complete: chalk.green("✓"),
|
|
7401
|
+
running: dim("·"),
|
|
7402
|
+
probe: chalk.yellow("?"),
|
|
7403
|
+
orphaned: chalk.yellow("!"),
|
|
7404
|
+
skip: dim("–"),
|
|
7405
|
+
wait: dim(" ")
|
|
7406
|
+
};
|
|
7407
|
+
console.log();
|
|
7408
|
+
for (const { decision, note } of report.decisions) {
|
|
7409
|
+
console.log(` ${mark[decision.action] ?? " "} ${decision.task.title}`);
|
|
7410
|
+
if (note) console.log(` ${dim(note)}`);
|
|
7411
|
+
}
|
|
7412
|
+
console.log();
|
|
7413
|
+
console.log(dim(` ${report.dispatched} dispatched, ${report.completed} completed, ${report.probed} probed, ${report.stuck} stuck`));
|
|
7414
|
+
console.log();
|
|
7415
|
+
});
|
|
7416
|
+
const scheduleCmd = taskCmd.command("schedule").description("Install, remove or inspect the launchd agent that ticks the scheduler");
|
|
7417
|
+
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) => {
|
|
7418
|
+
const r = installSchedule(opts.interval || DEFAULT_INTERVAL_SECS);
|
|
7419
|
+
console.log(r.loaded ? chalk.green(` ${r.message}`) : chalk.yellow(` ${r.message}`));
|
|
7420
|
+
console.log(dim(` ${r.plistPath}`));
|
|
7421
|
+
console.log(dim(` The schedule itself lives in Todoist — this only sets how often PAI looks.`));
|
|
7422
|
+
});
|
|
7423
|
+
scheduleCmd.command("uninstall").description("Remove the scheduler agent").action(() => console.log(chalk.green(` ${uninstallSchedule()}`)));
|
|
7424
|
+
scheduleCmd.command("status").description("Show whether the scheduler agent is installed and loaded").action(() => {
|
|
7425
|
+
const s = scheduleStatus();
|
|
7426
|
+
console.log();
|
|
7427
|
+
console.log(` ${s.installed ? chalk.green("installed") : dim("not installed")} ${s.installed ? s.running ? chalk.green("· loaded") : chalk.yellow("· not loaded") : ""}`);
|
|
7428
|
+
console.log(dim(` ${s.detail}`));
|
|
7429
|
+
console.log();
|
|
7430
|
+
});
|
|
6903
7431
|
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
7432
|
const raw = readConfigRaw();
|
|
6905
7433
|
const tasks = raw.tasks ?? {};
|
|
@@ -9133,4 +9661,4 @@ async function cmdPick(db, opts = {}) {
|
|
|
9133
9661
|
|
|
9134
9662
|
//#endregion
|
|
9135
9663
|
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-
|
|
9664
|
+
//# sourceMappingURL=pick-DxS6p2vI.mjs.map
|