claude-code-session-manager 0.3.1 → 0.4.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.
Files changed (29) hide show
  1. package/dist/assets/{cssMode-CKIxmaLj.js → cssMode-D33VcoUU.js} +1 -1
  2. package/dist/assets/{editor.main-B3LWGdFG.js → editor.main-Ci9p2pYg.js} +3 -3
  3. package/dist/assets/{freemarker2-BzFx3VJF.js → freemarker2-uWXQzH8G.js} +1 -1
  4. package/dist/assets/{handlebars-r19byd5F.js → handlebars-Dlo4_0Ou.js} +1 -1
  5. package/dist/assets/{html-BFGOpdkL.js → html-BwTZAQQp.js} +1 -1
  6. package/dist/assets/{htmlMode-BdwkFXKG.js → htmlMode-CTw3jPUf.js} +1 -1
  7. package/dist/assets/index-BandVNio.js +2971 -0
  8. package/dist/assets/index-CAtVp2FL.css +32 -0
  9. package/dist/assets/{javascript-B4jC2pwA.js → javascript-CXqrvCxj.js} +1 -1
  10. package/dist/assets/{jsonMode-Dwuw5t_L.js → jsonMode-6AzuDE4-.js} +1 -1
  11. package/dist/assets/{liquid-DzhrgdoQ.js → liquid-g2_3tZyL.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-BRdjkXT_.js → lspLanguageFeatures-sffb1qdE.js} +1 -1
  13. package/dist/assets/{mdx-CqU6wRQ2.js → mdx-Bis1dYhL.js} +1 -1
  14. package/dist/assets/{python-u7IU_Vcp.js → python-BhUyYPeZ.js} +1 -1
  15. package/dist/assets/{razor-C2yREyex.js → razor-CjFmPYdP.js} +1 -1
  16. package/dist/assets/{tsMode-BQqGO9nP.js → tsMode-B5Vq_w-B.js} +1 -1
  17. package/dist/assets/turnDetectorWorker-ChWji_-D.js +1 -0
  18. package/dist/assets/{typescript-DMFxh4fR.js → typescript-DYiI4Tub.js} +1 -1
  19. package/dist/assets/{whisperWorker-HvcbMQn6.js → whisperWorker-ivwFFLMj.js} +1 -1
  20. package/dist/assets/{xml-F2eG5pB_.js → xml-1xG1mvf4.js} +1 -1
  21. package/dist/assets/{yaml-C7sflBn2.js → yaml-wjhmrAxI.js} +1 -1
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/main/index.cjs +7 -0
  25. package/src/main/scheduler.cjs +549 -0
  26. package/src/preload/api.d.ts +53 -0
  27. package/src/preload/index.cjs +15 -0
  28. package/dist/assets/index-BzbwWnyF.css +0 -32
  29. package/dist/assets/index-C5FWtOH2.js +0 -2971
@@ -1 +1 @@
1
- import{l as e}from"./editor.main-B3LWGdFG.js";import"./index-C5FWtOH2.js";const o={comments:{blockComment:["<!--","-->"]},brackets:[["<",">"]],autoClosingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],surroundingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],onEnterRules:[{beforeText:new RegExp("<([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$","i"),afterText:/^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action:{indentAction:e.IndentAction.IndentOutdent}},{beforeText:new RegExp("<(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$","i"),action:{indentAction:e.IndentAction.Indent}}]},i={defaultToken:"",tokenPostfix:".xml",ignoreCase:!0,qualifiedName:/(?:[\w\.\-]+:)?[\w\.\-]+/,tokenizer:{root:[[/[^<&]+/,""],{include:"@whitespace"},[/(<)(@qualifiedName)/,[{token:"delimiter"},{token:"tag",next:"@tag"}]],[/(<\/)(@qualifiedName)(\s*)(>)/,[{token:"delimiter"},{token:"tag"},"",{token:"delimiter"}]],[/(<\?)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/(<\!)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/<\!\[CDATA\[/,{token:"delimiter.cdata",next:"@cdata"}],[/&\w+;/,"string.escape"]],cdata:[[/[^\]]+/,""],[/\]\]>/,{token:"delimiter.cdata",next:"@pop"}],[/\]/,""]],tag:[[/[ \t\r\n]+/,""],[/(@qualifiedName)(\s*=\s*)("[^"]*"|'[^']*')/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">?\/]*|'[^'>?\/]*)(?=[\?\/]\>)/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">]*|'[^'>]*)/,["attribute.name","","attribute.value"]],[/@qualifiedName/,"attribute.name"],[/\?>/,{token:"delimiter",next:"@pop"}],[/(\/)(>)/,[{token:"tag"},{token:"delimiter",next:"@pop"}]],[/>/,{token:"delimiter",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,""],[/<!--/,{token:"comment",next:"@comment"}]],comment:[[/[^<\-]+/,"comment.content"],[/-->/,{token:"comment",next:"@pop"}],[/<!--/,"comment.content.invalid"],[/[<\-]/,"comment.content"]]}};export{o as conf,i as language};
1
+ import{l as e}from"./editor.main-Ci9p2pYg.js";import"./index-BandVNio.js";const o={comments:{blockComment:["<!--","-->"]},brackets:[["<",">"]],autoClosingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],surroundingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],onEnterRules:[{beforeText:new RegExp("<([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$","i"),afterText:/^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action:{indentAction:e.IndentAction.IndentOutdent}},{beforeText:new RegExp("<(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$","i"),action:{indentAction:e.IndentAction.Indent}}]},i={defaultToken:"",tokenPostfix:".xml",ignoreCase:!0,qualifiedName:/(?:[\w\.\-]+:)?[\w\.\-]+/,tokenizer:{root:[[/[^<&]+/,""],{include:"@whitespace"},[/(<)(@qualifiedName)/,[{token:"delimiter"},{token:"tag",next:"@tag"}]],[/(<\/)(@qualifiedName)(\s*)(>)/,[{token:"delimiter"},{token:"tag"},"",{token:"delimiter"}]],[/(<\?)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/(<\!)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/<\!\[CDATA\[/,{token:"delimiter.cdata",next:"@cdata"}],[/&\w+;/,"string.escape"]],cdata:[[/[^\]]+/,""],[/\]\]>/,{token:"delimiter.cdata",next:"@pop"}],[/\]/,""]],tag:[[/[ \t\r\n]+/,""],[/(@qualifiedName)(\s*=\s*)("[^"]*"|'[^']*')/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">?\/]*|'[^'>?\/]*)(?=[\?\/]\>)/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">]*|'[^'>]*)/,["attribute.name","","attribute.value"]],[/@qualifiedName/,"attribute.name"],[/\?>/,{token:"delimiter",next:"@pop"}],[/(\/)(>)/,[{token:"tag"},{token:"delimiter",next:"@pop"}]],[/>/,{token:"delimiter",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,""],[/<!--/,{token:"comment",next:"@comment"}]],comment:[[/[^<\-]+/,"comment.content"],[/-->/,{token:"comment",next:"@pop"}],[/<!--/,"comment.content.invalid"],[/[<\-]/,"comment.content"]]}};export{o as conf,i as language};
@@ -1 +1 @@
1
- import{l as e}from"./editor.main-B3LWGdFG.js";import"./index-C5FWtOH2.js";const o={comments:{lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{offSide:!0},onEnterRules:[{beforeText:/:\s*$/,action:{indentAction:e.IndentAction.Indent}}]},r={tokenPostfix:".yaml",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.square",open:"[",close:"]"}],keywords:["true","True","TRUE","false","False","FALSE","null","Null","Null","~"],numberInteger:/(?:0|[+-]?[0-9]+)/,numberFloat:/(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,numberOctal:/0o[0-7]+/,numberHex:/0x[0-9a-fA-F]+/,numberInfinity:/[+-]?\.(?:inf|Inf|INF)/,numberNaN:/\.(?:nan|Nan|NAN)/,numberDate:/\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,escapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/%[^ ]+.*$/,"meta.directive"],[/---/,"operators.directivesEnd"],[/\.{3}/,"operators.documentEnd"],[/[-?:](?= )/,"operators"],{include:"@anchor"},{include:"@tagHandle"},{include:"@flowCollections"},{include:"@blockStyle"},[/@numberInteger(?![ \t]*\S+)/,"number"],[/@numberFloat(?![ \t]*\S+)/,"number.float"],[/@numberOctal(?![ \t]*\S+)/,"number.octal"],[/@numberHex(?![ \t]*\S+)/,"number.hex"],[/@numberInfinity(?![ \t]*\S+)/,"number.infinity"],[/@numberNaN(?![ \t]*\S+)/,"number.nan"],[/@numberDate(?![ \t]*\S+)/,"number.date"],[/(".*?"|'.*?'|[^#'"]*?)([ \t]*)(:)( |$)/,["type","white","operators","white"]],{include:"@flowScalars"},[/.+?(?=(\s+#|$))/,{cases:{"@keywords":"keyword","@default":"string"}}]],object:[{include:"@whitespace"},{include:"@comment"},[/\}/,"@brackets","@pop"],[/,/,"delimiter.comma"],[/:(?= )/,"operators"],[/(?:".*?"|'.*?'|[^,\{\[]+?)(?=: )/,"type"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\},]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],array:[{include:"@whitespace"},{include:"@comment"},[/\]/,"@brackets","@pop"],[/,/,"delimiter.comma"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\],]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],multiString:[[/^( +).+$/,"string","@multiStringContinued.$1"]],multiStringContinued:[[/^( *).+$/,{cases:{"$1==$S2":"string","@default":{token:"@rematch",next:"@popall"}}}]],whitespace:[[/[ \t\r\n]+/,"white"]],comment:[[/#.*$/,"comment"]],flowCollections:[[/\[/,"@brackets","@array"],[/\{/,"@brackets","@object"]],flowScalars:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'[^']*'/,"string"],[/"/,"string","@doubleQuotedString"]],doubleQuotedString:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],blockStyle:[[/[>|][0-9]*[+-]?$/,"operators","@multiString"]],flowNumber:[[/@numberInteger(?=[ \t]*[,\]\}])/,"number"],[/@numberFloat(?=[ \t]*[,\]\}])/,"number.float"],[/@numberOctal(?=[ \t]*[,\]\}])/,"number.octal"],[/@numberHex(?=[ \t]*[,\]\}])/,"number.hex"],[/@numberInfinity(?=[ \t]*[,\]\}])/,"number.infinity"],[/@numberNaN(?=[ \t]*[,\]\}])/,"number.nan"],[/@numberDate(?=[ \t]*[,\]\}])/,"number.date"]],tagHandle:[[/\![^ ]*/,"tag"]],anchor:[[/[&*][^ ]+/,"namespace"]]}};export{o as conf,r as language};
1
+ import{l as e}from"./editor.main-Ci9p2pYg.js";import"./index-BandVNio.js";const o={comments:{lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{offSide:!0},onEnterRules:[{beforeText:/:\s*$/,action:{indentAction:e.IndentAction.Indent}}]},r={tokenPostfix:".yaml",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.square",open:"[",close:"]"}],keywords:["true","True","TRUE","false","False","FALSE","null","Null","Null","~"],numberInteger:/(?:0|[+-]?[0-9]+)/,numberFloat:/(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,numberOctal:/0o[0-7]+/,numberHex:/0x[0-9a-fA-F]+/,numberInfinity:/[+-]?\.(?:inf|Inf|INF)/,numberNaN:/\.(?:nan|Nan|NAN)/,numberDate:/\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,escapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/%[^ ]+.*$/,"meta.directive"],[/---/,"operators.directivesEnd"],[/\.{3}/,"operators.documentEnd"],[/[-?:](?= )/,"operators"],{include:"@anchor"},{include:"@tagHandle"},{include:"@flowCollections"},{include:"@blockStyle"},[/@numberInteger(?![ \t]*\S+)/,"number"],[/@numberFloat(?![ \t]*\S+)/,"number.float"],[/@numberOctal(?![ \t]*\S+)/,"number.octal"],[/@numberHex(?![ \t]*\S+)/,"number.hex"],[/@numberInfinity(?![ \t]*\S+)/,"number.infinity"],[/@numberNaN(?![ \t]*\S+)/,"number.nan"],[/@numberDate(?![ \t]*\S+)/,"number.date"],[/(".*?"|'.*?'|[^#'"]*?)([ \t]*)(:)( |$)/,["type","white","operators","white"]],{include:"@flowScalars"},[/.+?(?=(\s+#|$))/,{cases:{"@keywords":"keyword","@default":"string"}}]],object:[{include:"@whitespace"},{include:"@comment"},[/\}/,"@brackets","@pop"],[/,/,"delimiter.comma"],[/:(?= )/,"operators"],[/(?:".*?"|'.*?'|[^,\{\[]+?)(?=: )/,"type"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\},]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],array:[{include:"@whitespace"},{include:"@comment"},[/\]/,"@brackets","@pop"],[/,/,"delimiter.comma"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\],]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],multiString:[[/^( +).+$/,"string","@multiStringContinued.$1"]],multiStringContinued:[[/^( *).+$/,{cases:{"$1==$S2":"string","@default":{token:"@rematch",next:"@popall"}}}]],whitespace:[[/[ \t\r\n]+/,"white"]],comment:[[/#.*$/,"comment"]],flowCollections:[[/\[/,"@brackets","@array"],[/\{/,"@brackets","@object"]],flowScalars:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'[^']*'/,"string"],[/"/,"string","@doubleQuotedString"]],doubleQuotedString:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],blockStyle:[[/[>|][0-9]*[+-]?$/,"operators","@multiString"]],flowNumber:[[/@numberInteger(?=[ \t]*[,\]\}])/,"number"],[/@numberFloat(?=[ \t]*[,\]\}])/,"number.float"],[/@numberOctal(?=[ \t]*[,\]\}])/,"number.octal"],[/@numberHex(?=[ \t]*[,\]\}])/,"number.hex"],[/@numberInfinity(?=[ \t]*[,\]\}])/,"number.infinity"],[/@numberNaN(?=[ \t]*[,\]\}])/,"number.nan"],[/@numberDate(?=[ \t]*[,\]\}])/,"number.date"]],tagHandle:[[/\![^ ]*/,"tag"]],anchor:[[/[&*][^ ]+/,"namespace"]]}};export{o as conf,r as language};
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Session Manager</title>
7
- <script type="module" crossorigin src="./assets/index-C5FWtOH2.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-BzbwWnyF.css">
7
+ <script type="module" crossorigin src="./assets/index-BandVNio.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-CAtVp2FL.css">
9
9
  </head>
10
10
  <body class="bg-bg text-fg font-mono antialiased">
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Local cockpit for Claude Code CLI sessions — terminal + full config surface.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -11,6 +11,7 @@ const billing = require('./usage.cjs');
11
11
  const logs = require('./logs.cjs');
12
12
  const voiceHotkey = require('./voiceHotkey.cjs');
13
13
  const voiceWizard = require('./voiceWizard.cjs');
14
+ const scheduler = require('./scheduler.cjs');
14
15
 
15
16
  let mainWindow = null;
16
17
  let rebooting = false;
@@ -96,6 +97,7 @@ async function rebootApp() {
96
97
  voiceHotkey.init(mainWindow).catch((e) => {
97
98
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
98
99
  });
100
+ scheduler.attachWindow(mainWindow);
99
101
  rebooting = false;
100
102
  return;
101
103
  }
@@ -193,6 +195,7 @@ billing.registerBillingHandlers();
193
195
  logs.registerLogHandlers();
194
196
  voiceHotkey.registerHotkeyHandlers();
195
197
  voiceWizard.registerWizardHandlers();
198
+ scheduler.registerScheduleHandlers();
196
199
 
197
200
  // --- App lifecycle ---
198
201
 
@@ -311,6 +314,10 @@ app.whenReady().then(async () => {
311
314
  voiceHotkey.init(mainWindow).catch((e) => {
312
315
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
313
316
  });
317
+ scheduler.attachWindow(mainWindow);
318
+ scheduler.init().catch((e) => {
319
+ logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
320
+ });
314
321
  });
315
322
 
316
323
  app.on('will-quit', () => {
@@ -0,0 +1,549 @@
1
+ /**
2
+ * scheduler.cjs — runs queued PRDs as headless `claude -p` jobs around
3
+ * the next 5h token-window reset.
4
+ *
5
+ * Layout (under ~/.claude/session-manager/scheduled-plans/):
6
+ * prds/NN-slug.md → user/Claude-authored PRD files (source of truth)
7
+ * queue.json → scheduler state: schedule, runs, status per PRD
8
+ * runs/<ISO>/<slug>.log → captured stdout/stderr for one execution
9
+ * runs/<ISO>/<slug>.meta.json → per-job metadata (exit, duration, cwd)
10
+ *
11
+ * Time:
12
+ * - "Next reset" comes from /api/oauth/usage five_hour.resets_at
13
+ * (already exposed via billing.fetchUsage()).
14
+ * - Trigger fires at resets_at + offsetMinutes (default 15).
15
+ * - We schedule a single setTimeout for the next fire-time. On schedule
16
+ * change (queue updated, reset_at moves) we cancel + reschedule.
17
+ *
18
+ * Parallelism:
19
+ * - PRD filename `NN-slug.md` — `NN` is the parallel group. All PRDs in
20
+ * the same group launch simultaneously; the next group only starts
21
+ * after the previous group's jobs all settle.
22
+ * - User-set concurrency cap (default 5) is the within-group ceiling. If
23
+ * a group has more PRDs than the cap, the excess waits until a slot
24
+ * frees in that group.
25
+ *
26
+ * Execution:
27
+ * - `claude -p "<PRD body>" --dangerously-skip-permissions` per PRD.
28
+ * - Stdout/stderr → runs/<ts>/<slug>.log; meta json gets exit + duration.
29
+ * - PRD frontmatter `cwd` → child cwd. Default: PROJECT_CWD const below.
30
+ *
31
+ * Persistence:
32
+ * - queue.json is the system of record for scheduling. Edited atomically
33
+ * via fs.writeFileSync(tmp) + rename.
34
+ * - On startup, walk prds/, ensure every .md has a queue.json entry.
35
+ * Orphaned entries (.md gone) are pruned.
36
+ *
37
+ * Renderer events:
38
+ * - 'schedule:state' broadcasts the full state on any change. Keeps the
39
+ * panel UI dead simple — no diff machinery.
40
+ */
41
+
42
+ const fs = require('node:fs');
43
+ const fsp = require('node:fs/promises');
44
+ const path = require('node:path');
45
+ const os = require('node:os');
46
+ const { spawn } = require('node:child_process');
47
+ const { ipcMain } = require('electron');
48
+ const billing = require('./usage.cjs');
49
+
50
+ const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
51
+ const PRDS_DIR = path.join(ROOT, 'prds');
52
+ const RUNS_DIR = path.join(ROOT, 'runs');
53
+ const QUEUE_PATH = path.join(ROOT, 'queue.json');
54
+ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
55
+
56
+ const DEFAULT_CONFIG = {
57
+ enabled: false,
58
+ offsetMinutes: 15,
59
+ concurrencyCap: 5,
60
+ defaultCwd: DEFAULT_PROJECT_CWD,
61
+ schemaVersion: 1,
62
+ };
63
+
64
+ // ---------- fs helpers ----------
65
+
66
+ function ensureDirs() {
67
+ fs.mkdirSync(PRDS_DIR, { recursive: true });
68
+ fs.mkdirSync(RUNS_DIR, { recursive: true });
69
+ }
70
+
71
+ function atomicWriteJson(p, data) {
72
+ const tmp = `${p}.${process.pid}.tmp`;
73
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
74
+ fs.renameSync(tmp, p);
75
+ }
76
+
77
+ function readQueue() {
78
+ try {
79
+ const raw = fs.readFileSync(QUEUE_PATH, 'utf8');
80
+ const data = JSON.parse(raw);
81
+ return {
82
+ config: { ...DEFAULT_CONFIG, ...(data.config || {}) },
83
+ jobs: Array.isArray(data.jobs) ? data.jobs : [],
84
+ scheduledFor: data.scheduledFor ?? null,
85
+ lastRunAt: data.lastRunAt ?? null,
86
+ };
87
+ } catch {
88
+ return { config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null };
89
+ }
90
+ }
91
+
92
+ function writeQueue(state) {
93
+ ensureDirs();
94
+ atomicWriteJson(QUEUE_PATH, state);
95
+ }
96
+
97
+ // ---------- PRD parsing ----------
98
+
99
+ /**
100
+ * Parse YAML-ish frontmatter — only the keys we use. We don't take a
101
+ * yaml dep; the schema is small (title, cwd, estimateMinutes, parallelGroup)
102
+ * and the format is documented in the user-facing README.
103
+ */
104
+ function parsePrd(filePath) {
105
+ const text = fs.readFileSync(filePath, 'utf8');
106
+ const meta = { title: null, cwd: null, estimateMinutes: null, parallelGroup: null };
107
+ let body = text;
108
+
109
+ if (text.startsWith('---\n')) {
110
+ const end = text.indexOf('\n---', 4);
111
+ if (end !== -1) {
112
+ const fm = text.slice(4, end);
113
+ body = text.slice(end + 4).replace(/^\n/, '');
114
+ for (const line of fm.split('\n')) {
115
+ const m = line.match(/^([a-zA-Z]+):\s*(.+?)\s*$/);
116
+ if (!m) continue;
117
+ const k = m[1];
118
+ let v = m[2];
119
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
120
+ v = v.slice(1, -1);
121
+ }
122
+ if (k === 'title') meta.title = v;
123
+ else if (k === 'cwd') meta.cwd = v;
124
+ else if (k === 'estimateMinutes') meta.estimateMinutes = Number(v) || null;
125
+ else if (k === 'parallelGroup') meta.parallelGroup = Number(v) || null;
126
+ }
127
+ }
128
+ }
129
+
130
+ const base = path.basename(filePath, '.md');
131
+ const groupFromName = (() => {
132
+ const m = base.match(/^(\d+)-/);
133
+ return m ? Number(m[1]) : null;
134
+ })();
135
+
136
+ return {
137
+ slug: base,
138
+ path: filePath,
139
+ title: meta.title || base,
140
+ cwd: meta.cwd || null,
141
+ estimateMinutes: meta.estimateMinutes,
142
+ parallelGroup: meta.parallelGroup ?? groupFromName ?? 99,
143
+ body: body.trim(),
144
+ };
145
+ }
146
+
147
+ function listPrdFiles() {
148
+ ensureDirs();
149
+ return fs.readdirSync(PRDS_DIR)
150
+ .filter((f) => f.endsWith('.md') && !f.startsWith('.'))
151
+ .map((f) => path.join(PRDS_DIR, f))
152
+ .sort();
153
+ }
154
+
155
+ // ---------- queue reconciliation ----------
156
+
157
+ /**
158
+ * Walk prds/, ensure every .md has a queue entry. Drop entries whose .md
159
+ * is gone. Refresh title/cwd/parallelGroup from disk every reconcile so
160
+ * editing the .md after queueing is honored.
161
+ *
162
+ * Status is preserved: pending stays pending, completed stays completed.
163
+ * Newly-discovered PRDs land as `pending`.
164
+ */
165
+ function reconcile(state) {
166
+ const files = listPrdFiles();
167
+ const onDisk = new Map();
168
+ for (const f of files) {
169
+ try {
170
+ const p = parsePrd(f);
171
+ onDisk.set(p.slug, p);
172
+ } catch (e) {
173
+ console.warn('[scheduler] failed to parse', f, e?.message);
174
+ }
175
+ }
176
+
177
+ const next = [];
178
+ const seen = new Set();
179
+ for (const job of state.jobs) {
180
+ const p = onDisk.get(job.slug);
181
+ if (!p) continue;
182
+ seen.add(job.slug);
183
+ next.push({
184
+ ...job,
185
+ title: p.title,
186
+ cwd: p.cwd,
187
+ parallelGroup: p.parallelGroup,
188
+ estimateMinutes: p.estimateMinutes,
189
+ bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
190
+ });
191
+ }
192
+ for (const [slug, p] of onDisk) {
193
+ if (seen.has(slug)) continue;
194
+ next.push({
195
+ slug,
196
+ title: p.title,
197
+ cwd: p.cwd,
198
+ parallelGroup: p.parallelGroup,
199
+ estimateMinutes: p.estimateMinutes,
200
+ bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
201
+ status: 'pending',
202
+ runId: null,
203
+ startedAt: null,
204
+ finishedAt: null,
205
+ exitCode: null,
206
+ error: null,
207
+ });
208
+ }
209
+ state.jobs = next.sort((a, b) => a.slug.localeCompare(b.slug));
210
+ return state;
211
+ }
212
+
213
+ // ---------- next-reset detection ----------
214
+
215
+ let cachedNextReset = { at: null, fetchedAt: 0 };
216
+
217
+ async function refreshNextReset() {
218
+ try {
219
+ const r = await billing.fetchUsage();
220
+ const at = r?.usage?.five_hour?.resets_at ?? null;
221
+ cachedNextReset = { at, fetchedAt: Date.now() };
222
+ return at;
223
+ } catch {
224
+ return cachedNextReset.at;
225
+ }
226
+ }
227
+
228
+ function getNextResetCached() {
229
+ return cachedNextReset.at;
230
+ }
231
+
232
+ // ---------- timer ----------
233
+
234
+ let mainWindow = null;
235
+ let fireTimer = null;
236
+ let isExecuting = false;
237
+ let claudeBinPathCached = null;
238
+
239
+ function attachWindow(w) { mainWindow = w; }
240
+
241
+ function broadcast() {
242
+ if (!mainWindow || mainWindow.isDestroyed()) return;
243
+ const state = readQueue();
244
+ reconcile(state);
245
+ writeQueue(state);
246
+ mainWindow.webContents.send('schedule:state', {
247
+ config: state.config,
248
+ jobs: state.jobs,
249
+ scheduledFor: state.scheduledFor,
250
+ lastRunAt: state.lastRunAt,
251
+ nextReset: getNextResetCached(),
252
+ });
253
+ }
254
+
255
+ function clearFireTimer() {
256
+ if (fireTimer) {
257
+ clearTimeout(fireTimer);
258
+ fireTimer = null;
259
+ }
260
+ }
261
+
262
+ function computeFireAt(state, nextResetIso) {
263
+ if (!state.config.enabled) return null;
264
+ if (!nextResetIso) return null;
265
+ const reset = new Date(nextResetIso).getTime();
266
+ if (Number.isNaN(reset)) return null;
267
+ return reset + (state.config.offsetMinutes * 60_000);
268
+ }
269
+
270
+ async function rescheduleTimer() {
271
+ clearFireTimer();
272
+ const state = readQueue();
273
+ reconcile(state);
274
+ const nextResetIso = await refreshNextReset();
275
+ const fireAt = computeFireAt(state, nextResetIso);
276
+ if (!fireAt) {
277
+ state.scheduledFor = null;
278
+ writeQueue(state);
279
+ broadcast();
280
+ return;
281
+ }
282
+
283
+ state.scheduledFor = new Date(fireAt).toISOString();
284
+ writeQueue(state);
285
+ broadcast();
286
+
287
+ const delay = Math.max(1000, fireAt - Date.now());
288
+ // setTimeout caps at int32 ms (~24.8 days) — well above our 5h horizon, so
289
+ // a single timer is fine. If reset_at is wildly in the future we'd still
290
+ // re-anchor on the next billing refresh.
291
+ fireTimer = setTimeout(() => { runDueJobs().catch(() => {}); }, delay);
292
+ console.log(`[scheduler] next fire in ${Math.round(delay / 1000)}s @ ${state.scheduledFor}`);
293
+ }
294
+
295
+ // ---------- claude binary ----------
296
+
297
+ function resolveClaudeBin() {
298
+ if (claudeBinPathCached) return claudeBinPathCached;
299
+ const candidates = [
300
+ path.join(os.homedir(), '.claude', 'local', 'claude'),
301
+ '/usr/local/bin/claude',
302
+ '/opt/homebrew/bin/claude',
303
+ '/usr/bin/claude',
304
+ ];
305
+ for (const c of candidates) {
306
+ try { fs.accessSync(c, fs.constants.X_OK); claudeBinPathCached = c; return c; } catch { /* */ }
307
+ }
308
+ // Last resort: rely on PATH lookup at spawn time.
309
+ claudeBinPathCached = 'claude';
310
+ return claudeBinPathCached;
311
+ }
312
+
313
+ // ---------- execution ----------
314
+
315
+ function pickRunDir() {
316
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
317
+ const dir = path.join(RUNS_DIR, ts);
318
+ fs.mkdirSync(dir, { recursive: true });
319
+ return { runId: ts, dir };
320
+ }
321
+
322
+ async function executeJob(job, runDir, defaultCwd) {
323
+ const logPath = path.join(runDir, `${job.slug}.log`);
324
+ const metaPath = path.join(runDir, `${job.slug}.meta.json`);
325
+ const cwd = job.cwd || defaultCwd;
326
+ const startedAt = Date.now();
327
+
328
+ const fd = fs.openSync(logPath, 'a');
329
+ fs.writeSync(fd, `[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}\n\n`);
330
+
331
+ // Read full PRD body fresh from disk (queue stored only the preview).
332
+ let prompt;
333
+ try {
334
+ const parsed = parsePrd(path.join(PRDS_DIR, `${job.slug}.md`));
335
+ prompt = parsed.body;
336
+ } catch (e) {
337
+ fs.writeSync(fd, `[scheduler] failed to read PRD: ${e?.message}\n`);
338
+ fs.closeSync(fd);
339
+ return { exitCode: -1, durationMs: 0, error: e?.message };
340
+ }
341
+
342
+ return await new Promise((resolve) => {
343
+ const claudeBin = resolveClaudeBin();
344
+ const child = spawn(claudeBin, [
345
+ '-p', prompt,
346
+ '--dangerously-skip-permissions',
347
+ '--output-format', 'stream-json',
348
+ '--verbose',
349
+ ], {
350
+ cwd,
351
+ env: process.env,
352
+ stdio: ['ignore', fd, fd],
353
+ });
354
+
355
+ fs.writeSync(fd, `[scheduler] spawned pid=${child.pid}\n\n`);
356
+
357
+ child.on('error', (err) => {
358
+ const durationMs = Date.now() - startedAt;
359
+ fs.writeSync(fd, `\n[scheduler] spawn error: ${err.message}\n`);
360
+ fs.closeSync(fd);
361
+ atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: -1, error: err.message, startedAt, finishedAt: Date.now(), durationMs });
362
+ resolve({ exitCode: -1, durationMs, error: err.message });
363
+ });
364
+
365
+ child.on('exit', (code) => {
366
+ const durationMs = Date.now() - startedAt;
367
+ fs.writeSync(fd, `\n[scheduler] exit code=${code} duration=${Math.round(durationMs / 1000)}s\n`);
368
+ fs.closeSync(fd);
369
+ atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: code, startedAt, finishedAt: Date.now(), durationMs });
370
+ resolve({ exitCode: code, durationMs });
371
+ });
372
+ });
373
+ }
374
+
375
+ async function runDueJobs() {
376
+ if (isExecuting) return;
377
+ isExecuting = true;
378
+ try {
379
+ const state = readQueue();
380
+ reconcile(state);
381
+ const pending = state.jobs.filter((j) => j.status === 'pending');
382
+ if (pending.length === 0) {
383
+ isExecuting = false;
384
+ return;
385
+ }
386
+ const { runId, dir: runDir } = pickRunDir();
387
+ state.lastRunAt = new Date().toISOString();
388
+
389
+ // Group by parallelGroup, ascending. Each group runs serially after the
390
+ // previous group completes.
391
+ const groups = new Map();
392
+ for (const j of pending) {
393
+ const g = j.parallelGroup ?? 99;
394
+ if (!groups.has(g)) groups.set(g, []);
395
+ groups.get(g).push(j);
396
+ }
397
+ const groupKeys = Array.from(groups.keys()).sort((a, b) => a - b);
398
+
399
+ writeQueue(state);
400
+ broadcast();
401
+
402
+ for (const gk of groupKeys) {
403
+ const groupJobs = groups.get(gk);
404
+ // Within a group: cap concurrency and run waves until all done.
405
+ const cap = Math.max(1, Math.min(state.config.concurrencyCap, groupJobs.length));
406
+ const queue = [...groupJobs];
407
+ const inFlight = new Set();
408
+
409
+ const launch = (job) => {
410
+ const s = readQueue();
411
+ const idx = s.jobs.findIndex((x) => x.slug === job.slug);
412
+ if (idx >= 0) {
413
+ s.jobs[idx].status = 'running';
414
+ s.jobs[idx].runId = runId;
415
+ s.jobs[idx].startedAt = new Date().toISOString();
416
+ writeQueue(s);
417
+ broadcast();
418
+ }
419
+ const promise = executeJob(job, runDir, state.config.defaultCwd).then((res) => {
420
+ const sn = readQueue();
421
+ const i2 = sn.jobs.findIndex((x) => x.slug === job.slug);
422
+ if (i2 >= 0) {
423
+ sn.jobs[i2].status = res.exitCode === 0 ? 'completed' : 'failed';
424
+ sn.jobs[i2].finishedAt = new Date().toISOString();
425
+ sn.jobs[i2].exitCode = res.exitCode;
426
+ sn.jobs[i2].error = res.error || null;
427
+ writeQueue(sn);
428
+ broadcast();
429
+ }
430
+ inFlight.delete(promise);
431
+ });
432
+ inFlight.add(promise);
433
+ };
434
+
435
+ // Prime up to cap
436
+ while (queue.length && inFlight.size < cap) launch(queue.shift());
437
+ // Drain
438
+ while (inFlight.size > 0) {
439
+ await Promise.race(inFlight);
440
+ while (queue.length && inFlight.size < cap) launch(queue.shift());
441
+ }
442
+ }
443
+ } finally {
444
+ isExecuting = false;
445
+ // After a run, disable auto-fire so the user has to opt-in for the next reset.
446
+ const s = readQueue();
447
+ s.config.enabled = false;
448
+ s.scheduledFor = null;
449
+ writeQueue(s);
450
+ broadcast();
451
+ }
452
+ }
453
+
454
+ // ---------- IPC ----------
455
+
456
+ function registerScheduleHandlers() {
457
+ ensureDirs();
458
+
459
+ ipcMain.handle('schedule:state', async () => {
460
+ const state = readQueue();
461
+ reconcile(state);
462
+ writeQueue(state);
463
+ return {
464
+ config: state.config,
465
+ jobs: state.jobs,
466
+ scheduledFor: state.scheduledFor,
467
+ lastRunAt: state.lastRunAt,
468
+ nextReset: getNextResetCached(),
469
+ paths: { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH },
470
+ };
471
+ });
472
+
473
+ ipcMain.handle('schedule:set-config', async (_e, partial) => {
474
+ const state = readQueue();
475
+ state.config = { ...state.config, ...(partial || {}) };
476
+ if (typeof state.config.concurrencyCap === 'number') {
477
+ state.config.concurrencyCap = Math.max(1, Math.min(20, Math.floor(state.config.concurrencyCap)));
478
+ }
479
+ if (typeof state.config.offsetMinutes === 'number') {
480
+ state.config.offsetMinutes = Math.max(0, Math.min(180, Math.floor(state.config.offsetMinutes)));
481
+ }
482
+ writeQueue(state);
483
+ await rescheduleTimer();
484
+ return { ok: true, config: state.config };
485
+ });
486
+
487
+ ipcMain.handle('schedule:reset-job', async (_e, { slug }) => {
488
+ const state = readQueue();
489
+ const idx = state.jobs.findIndex((j) => j.slug === slug);
490
+ if (idx < 0) return { ok: false, error: 'not found' };
491
+ state.jobs[idx].status = 'pending';
492
+ state.jobs[idx].runId = null;
493
+ state.jobs[idx].startedAt = null;
494
+ state.jobs[idx].finishedAt = null;
495
+ state.jobs[idx].exitCode = null;
496
+ state.jobs[idx].error = null;
497
+ writeQueue(state);
498
+ broadcast();
499
+ return { ok: true };
500
+ });
501
+
502
+ ipcMain.handle('schedule:run-now', async () => {
503
+ runDueJobs().catch((e) => console.error('[scheduler] runDueJobs error', e));
504
+ return { ok: true };
505
+ });
506
+
507
+ ipcMain.handle('schedule:refresh-reset', async () => {
508
+ const at = await refreshNextReset();
509
+ await rescheduleTimer();
510
+ return { ok: true, nextReset: at };
511
+ });
512
+
513
+ ipcMain.handle('schedule:open-folder', async () => {
514
+ const { shell } = require('electron');
515
+ await shell.openPath(ROOT);
516
+ return { ok: true };
517
+ });
518
+
519
+ ipcMain.handle('schedule:read-prd', async (_e, { slug }) => {
520
+ try {
521
+ const text = await fsp.readFile(path.join(PRDS_DIR, `${slug}.md`), 'utf8');
522
+ return { ok: true, text };
523
+ } catch (e) {
524
+ return { ok: false, error: e?.message };
525
+ }
526
+ });
527
+
528
+ ipcMain.handle('schedule:read-log', async (_e, { runId, slug }) => {
529
+ try {
530
+ const p = path.join(RUNS_DIR, runId, `${slug}.log`);
531
+ const text = await fsp.readFile(p, 'utf8');
532
+ return { ok: true, text };
533
+ } catch (e) {
534
+ return { ok: false, error: e?.message };
535
+ }
536
+ });
537
+ }
538
+
539
+ async function init() {
540
+ ensureDirs();
541
+ // Ensure queue.json exists with defaults so the renderer can read it.
542
+ if (!fs.existsSync(QUEUE_PATH)) writeQueue({ config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null });
543
+ await rescheduleTimer();
544
+ // Refresh next-reset every 10 minutes — billing window can shift if usage
545
+ // resets early or the auth token rotates.
546
+ setInterval(() => { rescheduleTimer().catch(() => {}); }, 10 * 60_000);
547
+ }
548
+
549
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR };