@tekmidian/pai 0.13.4 → 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-CmG7SCtp.mjs → pick-DxS6p2vI.mjs} +625 -25
- 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-CmG7SCtp.mjs.map +0 -1
|
@@ -2670,6 +2670,86 @@ function registerRestoreCommands(program) {
|
|
|
2670
2670
|
});
|
|
2671
2671
|
}
|
|
2672
2672
|
|
|
2673
|
+
//#endregion
|
|
2674
|
+
//#region src/config/json-store.ts
|
|
2675
|
+
/**
|
|
2676
|
+
* json-store.ts — read/write JSON config files without destroying them
|
|
2677
|
+
*
|
|
2678
|
+
* The failure this exists to prevent:
|
|
2679
|
+
*
|
|
2680
|
+
* try { return JSON.parse(read(path)); } catch { return {}; }
|
|
2681
|
+
* ... later ...
|
|
2682
|
+
* write(path, JSON.stringify(ourData));
|
|
2683
|
+
*
|
|
2684
|
+
* An unreadable file becomes an empty object, and the next write makes that
|
|
2685
|
+
* permanent. Silently, exit code 0. This shape appeared three times in this
|
|
2686
|
+
* repo against three different files, and twice in AIBroker.
|
|
2687
|
+
*
|
|
2688
|
+
* The distinction that matters is between *missing* and *unreadable*:
|
|
2689
|
+
*
|
|
2690
|
+
* missing — legitimate first run. Start fresh; writing is safe.
|
|
2691
|
+
* unreadable — the file exists and we could not parse it. Those bytes are
|
|
2692
|
+
* the only copy of something. Never overwrite them.
|
|
2693
|
+
*
|
|
2694
|
+
* Collapsing the second into the first is the bug.
|
|
2695
|
+
*
|
|
2696
|
+
* NOT everything deserves this guard. For a transient buffer — an undelivered
|
|
2697
|
+
* message queue, a cache — starting fresh IS the correct recovery, and
|
|
2698
|
+
* refusing to write would disable the feature permanently. Use `writeJsonAtomic`
|
|
2699
|
+
* alone there: it still prevents a crash from truncating a good file, without
|
|
2700
|
+
* blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
|
|
2701
|
+
*/
|
|
2702
|
+
/**
|
|
2703
|
+
* Read a JSON file, distinguishing "absent" from "damaged".
|
|
2704
|
+
*
|
|
2705
|
+
* @param path file to read
|
|
2706
|
+
* @param label how to name it to the user, e.g. "~/.claude.json"
|
|
2707
|
+
* @throws if the file exists but cannot be read or parsed
|
|
2708
|
+
*/
|
|
2709
|
+
function readJsonStrict(path, label = path) {
|
|
2710
|
+
if (!existsSync(path)) return {};
|
|
2711
|
+
let raw;
|
|
2712
|
+
try {
|
|
2713
|
+
raw = readFileSync(path, "utf8");
|
|
2714
|
+
} catch (e) {
|
|
2715
|
+
throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
|
|
2716
|
+
}
|
|
2717
|
+
try {
|
|
2718
|
+
return JSON.parse(raw);
|
|
2719
|
+
} catch (e) {
|
|
2720
|
+
throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Write JSON without risking the existing file.
|
|
2725
|
+
*
|
|
2726
|
+
* Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
|
|
2727
|
+
* and renames. Rename is atomic within a filesystem, so a crash mid-write
|
|
2728
|
+
* leaves the original intact rather than truncated — which is how these files
|
|
2729
|
+
* become corrupt in the first place.
|
|
2730
|
+
*/
|
|
2731
|
+
function writeJsonAtomic(path, data, opts = {}) {
|
|
2732
|
+
const { backup = true, label = path } = opts;
|
|
2733
|
+
const serialized = JSON.stringify(data, null, 2) + "\n";
|
|
2734
|
+
const dir = dirname(path);
|
|
2735
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2736
|
+
if (backup && existsSync(path)) try {
|
|
2737
|
+
copyFileSync(path, `${path}.bak-pai`);
|
|
2738
|
+
} catch (e) {
|
|
2739
|
+
throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
|
|
2740
|
+
}
|
|
2741
|
+
const tmp = `${path}.tmp-pai-${process.pid}`;
|
|
2742
|
+
try {
|
|
2743
|
+
writeFileSync(tmp, serialized, "utf8");
|
|
2744
|
+
renameSync(tmp, path);
|
|
2745
|
+
} catch (e) {
|
|
2746
|
+
try {
|
|
2747
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
2748
|
+
} catch {}
|
|
2749
|
+
throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2673
2753
|
//#endregion
|
|
2674
2754
|
//#region src/cli/commands/setup/utils.ts
|
|
2675
2755
|
/**
|
|
@@ -2741,17 +2821,18 @@ async function promptYesNo(rl, question, defaultYes = true) {
|
|
|
2741
2821
|
if (answer === "") return defaultYes;
|
|
2742
2822
|
return answer.toLowerCase().startsWith("y");
|
|
2743
2823
|
}
|
|
2824
|
+
/**
|
|
2825
|
+
* This file holds the Postgres connection string, the storage backend choice,
|
|
2826
|
+
* notification routing and any tracker API token — none of which the user can
|
|
2827
|
+
* reconstruct from memory. It previously returned {} on a parse failure and
|
|
2828
|
+
* then overwrote the file, so a damaged config was replaced by whatever the
|
|
2829
|
+
* current command happened to be setting.
|
|
2830
|
+
*/
|
|
2744
2831
|
function readConfigRaw() {
|
|
2745
|
-
|
|
2746
|
-
try {
|
|
2747
|
-
return JSON.parse(readFileSync(CONFIG_FILE$2, "utf-8"));
|
|
2748
|
-
} catch {
|
|
2749
|
-
return {};
|
|
2750
|
-
}
|
|
2832
|
+
return readJsonStrict(CONFIG_FILE$2, "~/.config/pai/config.json");
|
|
2751
2833
|
}
|
|
2752
2834
|
function writeConfigRaw(data) {
|
|
2753
|
-
|
|
2754
|
-
writeFileSync(CONFIG_FILE$2, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
2835
|
+
writeJsonAtomic(CONFIG_FILE$2, data, { label: "~/.config/pai/config.json" });
|
|
2755
2836
|
}
|
|
2756
2837
|
function mergeConfig(updates) {
|
|
2757
2838
|
const current = readConfigRaw();
|
|
@@ -3434,27 +3515,18 @@ async function stepTsHooks(rl) {
|
|
|
3434
3515
|
|
|
3435
3516
|
//#endregion
|
|
3436
3517
|
//#region src/cli/commands/settings-manager.ts
|
|
3518
|
+
const SETTINGS_FILE = join(join(homedir(), ".claude"), "settings.json");
|
|
3437
3519
|
/**
|
|
3438
|
-
* settings
|
|
3439
|
-
*
|
|
3440
|
-
*
|
|
3441
|
-
*
|
|
3442
|
-
* - hooks: appended per hookType, deduplicated by command string
|
|
3443
|
-
* - statusLine: written only if the key is not already present
|
|
3520
|
+
* ~/.claude/settings.json is Claude Code's own file, not ours: hooks, env,
|
|
3521
|
+
* permissions, statusline, enabled plugins. PAI only ever adds to it. Returning
|
|
3522
|
+
* {} for a damaged file and then writing meant a stray parse error could strip
|
|
3523
|
+
* every hook registration the user had.
|
|
3444
3524
|
*/
|
|
3445
|
-
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
3446
|
-
const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
|
|
3447
3525
|
function readSettingsJson() {
|
|
3448
|
-
|
|
3449
|
-
try {
|
|
3450
|
-
return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
|
|
3451
|
-
} catch {
|
|
3452
|
-
return {};
|
|
3453
|
-
}
|
|
3526
|
+
return readJsonStrict(SETTINGS_FILE, "~/.claude/settings.json");
|
|
3454
3527
|
}
|
|
3455
3528
|
function writeSettingsJson(data) {
|
|
3456
|
-
|
|
3457
|
-
writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
3529
|
+
writeJsonAtomic(SETTINGS_FILE, data, { label: "~/.claude/settings.json" });
|
|
3458
3530
|
}
|
|
3459
3531
|
/**
|
|
3460
3532
|
* Merge env vars — add keys that are absent, never overwrite existing ones.
|
|
@@ -4256,6 +4328,33 @@ var TodoistProvider = class {
|
|
|
4256
4328
|
sourceUrl: `https://app.todoist.com/app/task/${created.id}`
|
|
4257
4329
|
};
|
|
4258
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
|
+
}
|
|
4259
4358
|
async complete(id) {
|
|
4260
4359
|
const token = this.token();
|
|
4261
4360
|
if (!token) throw new Error("Todoist provider is not configured — run `pai setup`.");
|
|
@@ -6663,6 +6762,77 @@ var AiBrokerTransport = class {
|
|
|
6663
6762
|
}
|
|
6664
6763
|
};
|
|
6665
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
|
+
/**
|
|
6666
6836
|
* Return a transport if the `aibroker` CLI is present and supports dispatch.
|
|
6667
6837
|
*
|
|
6668
6838
|
* Probing for the subcommand rather than just the binary matters: AIBroker
|
|
@@ -6682,6 +6852,388 @@ async function detectAiBroker(bin = "aibroker", timeoutSecs = DEFAULT_DISPATCH_T
|
|
|
6682
6852
|
}
|
|
6683
6853
|
}
|
|
6684
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
|
+
|
|
6685
7237
|
//#endregion
|
|
6686
7238
|
//#region src/cli/commands/task.ts
|
|
6687
7239
|
const dim = chalk.dim;
|
|
@@ -6828,6 +7380,54 @@ function registerTaskCommands(taskCmd) {
|
|
|
6828
7380
|
spawnIfAbsent: opts.spawn !== false
|
|
6829
7381
|
}));
|
|
6830
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
|
+
});
|
|
6831
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) => {
|
|
6832
7432
|
const raw = readConfigRaw();
|
|
6833
7433
|
const tasks = raw.tasks ?? {};
|
|
@@ -9061,4 +9661,4 @@ async function cmdPick(db, opts = {}) {
|
|
|
9061
9661
|
|
|
9062
9662
|
//#endregion
|
|
9063
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 };
|
|
9064
|
-
//# sourceMappingURL=pick-
|
|
9664
|
+
//# sourceMappingURL=pick-DxS6p2vI.mjs.map
|