claude-code-session-manager 0.35.2 → 0.35.3

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 (31) hide show
  1. package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-DmCekkUQ.js} +1 -1
  2. package/dist/assets/index-CHKMzzCM.js +3558 -0
  3. package/dist/assets/index-DVqmrWP3.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -2
  6. package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
  8. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
  9. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  10. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +10 -6
  11. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +3 -2
  12. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +12 -7
  13. package/src/main/__tests__/adminServer.test.cjs +175 -0
  14. package/src/main/__tests__/runVerify.test.cjs +74 -4
  15. package/src/main/adminServer.cjs +157 -0
  16. package/src/main/browserCapture.cjs +714 -0
  17. package/src/main/browserView.cjs +613 -0
  18. package/src/main/config.cjs +37 -1
  19. package/src/main/historyAggregator.cjs +92 -6
  20. package/src/main/index.cjs +32 -3
  21. package/src/main/ipcSchemas.cjs +106 -0
  22. package/src/main/lib/schedulerConfig.cjs +6 -2
  23. package/src/main/runVerify.cjs +14 -1
  24. package/src/main/scheduler.cjs +6 -1
  25. package/src/main/sessionsStore.cjs +4 -3
  26. package/src/preload/api.d.ts +129 -5
  27. package/src/preload/browserViewPreload.cjs +67 -0
  28. package/src/preload/index.cjs +55 -5
  29. package/dist/assets/index-2Om-ouz6.js +0 -3534
  30. package/dist/assets/index-DeIJ8SM5.css +0 -32
  31. package/src/main/docEditor.cjs +0 -92
@@ -0,0 +1,175 @@
1
+ /**
2
+ * adminServer.test.cjs — unit tests for the loopback-only scheduler admin API.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/adminServer.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const http = require('node:http');
12
+ const { createAdminServer } = require('../adminServer.cjs');
13
+
14
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
15
+ return new Promise((resolve, reject) => {
16
+ const headers = {};
17
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
18
+ let payload;
19
+ if (body !== undefined) {
20
+ payload = JSON.stringify(body);
21
+ headers['Content-Type'] = 'application/json';
22
+ headers['Content-Length'] = Buffer.byteLength(payload);
23
+ }
24
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
25
+ const chunks = [];
26
+ res.on('data', (c) => chunks.push(c));
27
+ res.on('end', () => {
28
+ const text = Buffer.concat(chunks).toString('utf8');
29
+ let json = null;
30
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
31
+ resolve({ status: res.statusCode, json, text });
32
+ });
33
+ });
34
+ req.on('error', reject);
35
+ if (payload) req.write(payload);
36
+ req.end();
37
+ });
38
+ }
39
+
40
+ function makeFakeRemote({ jobs = [] } = {}) {
41
+ return {
42
+ async listJobs() {
43
+ return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
44
+ },
45
+ async resetJob(slug) {
46
+ const job = jobs.find((j) => j.slug === slug);
47
+ if (!job) return { ok: false, error: 'not found' };
48
+ job.status = 'pending';
49
+ job.runId = null;
50
+ job.startedAt = null;
51
+ job.finishedAt = null;
52
+ job.exitCode = null;
53
+ job.error = null;
54
+ delete job.runtime;
55
+ delete job.verifierVerdict;
56
+ return { ok: true, slug, status: 'pending' };
57
+ },
58
+ };
59
+ }
60
+
61
+ test('request without bearer token returns 401', async () => {
62
+ const remote = makeFakeRemote();
63
+ const admin = createAdminServer(remote);
64
+ const { port } = await admin.start();
65
+ try {
66
+ const res = await request(port, { path: '/admin/scheduler/jobs' });
67
+ assert.strictEqual(res.status, 401);
68
+ assert.strictEqual(res.json.ok, false);
69
+ } finally {
70
+ await admin.stop();
71
+ }
72
+ });
73
+
74
+ test('request with wrong bearer token returns 401', async () => {
75
+ const remote = makeFakeRemote();
76
+ const admin = createAdminServer(remote);
77
+ const { port } = await admin.start();
78
+ try {
79
+ const res = await request(port, { path: '/admin/scheduler/jobs', token: 'not-the-token' });
80
+ assert.strictEqual(res.status, 401);
81
+ } finally {
82
+ await admin.stop();
83
+ }
84
+ });
85
+
86
+ test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
87
+ const remote = makeFakeRemote({
88
+ jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
89
+ });
90
+ const admin = createAdminServer(remote);
91
+ const { port, token } = await admin.start();
92
+ try {
93
+ const res = await request(port, { path: '/admin/scheduler/jobs', token });
94
+ assert.strictEqual(res.status, 200);
95
+ assert.ok(Array.isArray(res.json));
96
+ assert.strictEqual(res.json.length, 1);
97
+ assert.strictEqual(res.json[0].slug, '10-foo');
98
+ } finally {
99
+ await admin.stop();
100
+ }
101
+ });
102
+
103
+ test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
104
+ const remote = makeFakeRemote({ jobs: [] });
105
+ const admin = createAdminServer(remote);
106
+ const { port, token } = await admin.start();
107
+ try {
108
+ const res = await request(port, {
109
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
110
+ });
111
+ assert.strictEqual(res.status, 200);
112
+ assert.strictEqual(res.json.ok, false);
113
+ } finally {
114
+ await admin.stop();
115
+ }
116
+ });
117
+
118
+ test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
119
+ const job = {
120
+ slug: '10-foo',
121
+ title: 'Foo',
122
+ status: 'completed',
123
+ cwd: '/tmp/foo',
124
+ runId: 'run-1',
125
+ startedAt: 100,
126
+ finishedAt: 200,
127
+ exitCode: 0,
128
+ error: null,
129
+ runtime: { pid: 1234 },
130
+ verifierVerdict: 'PASS',
131
+ };
132
+ const remote = makeFakeRemote({ jobs: [job] });
133
+ const admin = createAdminServer(remote);
134
+ const { port, token } = await admin.start();
135
+ try {
136
+ const res = await request(port, {
137
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
138
+ });
139
+ assert.strictEqual(res.status, 200);
140
+ assert.deepStrictEqual(res.json, { ok: true, slug: '10-foo', status: 'pending' });
141
+ assert.strictEqual(job.status, 'pending');
142
+ assert.strictEqual(job.runId, null);
143
+ assert.strictEqual(job.startedAt, null);
144
+ assert.strictEqual(job.finishedAt, null);
145
+ assert.strictEqual(job.exitCode, null);
146
+ assert.strictEqual(job.error, null);
147
+ assert.strictEqual('runtime' in job, false);
148
+ assert.strictEqual('verifierVerdict' in job, false);
149
+ } finally {
150
+ await admin.stop();
151
+ }
152
+ });
153
+
154
+ test('unknown path/method returns 404', async () => {
155
+ const remote = makeFakeRemote();
156
+ const admin = createAdminServer(remote);
157
+ const { port, token } = await admin.start();
158
+ try {
159
+ const res = await request(port, { path: '/admin/scheduler/nope', token });
160
+ assert.strictEqual(res.status, 404);
161
+ } finally {
162
+ await admin.stop();
163
+ }
164
+ });
165
+
166
+ test('server binds only to 127.0.0.1', async () => {
167
+ const remote = makeFakeRemote();
168
+ const admin = createAdminServer(remote);
169
+ await admin.start();
170
+ try {
171
+ assert.strictEqual(admin.server.address().address, '127.0.0.1');
172
+ } finally {
173
+ await admin.stop();
174
+ }
175
+ });
@@ -285,7 +285,7 @@ test('Truly clean run (no error markers) → clean/null', async () => {
285
285
  {
286
286
  type: 'result',
287
287
  subtype: 'success',
288
- result: 'Feature complete. All acceptance criteria satisfied.',
288
+ result: 'Feature complete. All acceptance criteria satisfied.\nSCHEDULER_VERDICT: PASS',
289
289
  },
290
290
  ];
291
291
 
@@ -367,7 +367,7 @@ test('FAIL recovered within 30 events → clean', async () => {
367
367
  {
368
368
  type: 'result',
369
369
  subtype: 'success',
370
- result: 'Fixed the failing test and verified all pass.',
370
+ result: 'Fixed the failing test and verified all pass.\nSCHEDULER_VERDICT: PASS',
371
371
  },
372
372
  ];
373
373
 
@@ -416,7 +416,7 @@ function bashRunEvents(content, { toolName = 'Bash', resultSubtype = 'success' }
416
416
  }],
417
417
  },
418
418
  },
419
- { type: 'result', subtype: resultSubtype, result: 'All acceptance criteria verified.' },
419
+ { type: 'result', subtype: resultSubtype, result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' },
420
420
  ];
421
421
  }
422
422
 
@@ -568,7 +568,7 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
568
568
  events.push({ type: 'user', message: { role: 'user', content: [
569
569
  { type: 'tool_result', tool_use_id: 'tbad',
570
570
  content: '<tool_use_error>Error: No such tool available: bash</tool_use_error>', is_error: true }] } });
571
- events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
571
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
572
572
 
573
573
  writeLog(tmp, slug, events);
574
574
  const prdPath = writePrd(tmp, slug, '# Correctness batch');
@@ -809,6 +809,76 @@ test('no sentinel + committed + allowPreSentinelHeal=false (default) → transcr
809
809
  }
810
810
  });
811
811
 
812
+ // ─── no_verdict_sentinel: no commit + no sentinel is not "clean" by default ──
813
+
814
+ /** A run with zero tool_result pattern hits — e.g. the agent asked a
815
+ * clarifying question and stopped, ending its turn without touching tools. */
816
+ function noOpRunEvents(resultText) {
817
+ return [
818
+ { type: 'result', subtype: 'success', result: resultText },
819
+ ];
820
+ }
821
+
822
+ test('no_verdict_sentinel: no tool_result issues, no sentinel, no commit → needs_review', async () => {
823
+ const tmp = makeTmpDir();
824
+ try {
825
+ const slug = '406-browser-capture-panel-ui';
826
+ writeLog(tmp, slug, noOpRunEvents('I need clarification on which capture API to target before proceeding.'));
827
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
828
+ const verdict = await verifyRun({
829
+ runDir: tmp,
830
+ prdPath,
831
+ queueEntry: { slug, status: 'running' },
832
+ allJobs: [],
833
+ committedDuringRun: false,
834
+ });
835
+ assert.equal(verdict.verdict, 'no_verdict_sentinel', `expected no_verdict_sentinel, got ${verdict.verdict}: ${verdict.reason}`);
836
+ assert.equal(verdict.downgradeTo, 'needs_review');
837
+ } finally {
838
+ rmdir(tmp);
839
+ }
840
+ });
841
+
842
+ test('no_verdict_sentinel guard: same run but committedDuringRun:true → stays clean', async () => {
843
+ const tmp = makeTmpDir();
844
+ try {
845
+ const slug = '406-browser-capture-panel-ui-committed';
846
+ writeLog(tmp, slug, noOpRunEvents('I need clarification on which capture API to target before proceeding.'));
847
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
848
+ const verdict = await verifyRun({
849
+ runDir: tmp,
850
+ prdPath,
851
+ queueEntry: { slug, status: 'running' },
852
+ allJobs: [],
853
+ committedDuringRun: true,
854
+ });
855
+ assert.equal(verdict.verdict, 'clean', `commit landed should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
856
+ assert.equal(verdict.downgradeTo, null);
857
+ } finally {
858
+ rmdir(tmp);
859
+ }
860
+ });
861
+
862
+ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel → stays clean', async () => {
863
+ const tmp = makeTmpDir();
864
+ try {
865
+ const slug = '406-browser-capture-panel-ui-sentinel';
866
+ writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
867
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
868
+ const verdict = await verifyRun({
869
+ runDir: tmp,
870
+ prdPath,
871
+ queueEntry: { slug, status: 'running' },
872
+ allJobs: [],
873
+ committedDuringRun: false,
874
+ });
875
+ assert.equal(verdict.verdict, 'clean', `PASS sentinel should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
876
+ assert.equal(verdict.downgradeTo, null);
877
+ } finally {
878
+ rmdir(tmp);
879
+ }
880
+ });
881
+
812
882
  // (e) halt + sentinel PASS → still halt (override must not apply to halt)
813
883
  test('halt result + sentinel PASS + committedDuringRun:true → still halt', async () => {
814
884
  const tmp = makeTmpDir();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * adminServer.cjs — loopback-only HTTP admin API for the scheduler.
3
+ *
4
+ * Foundation for PRD 449 (an MCP server wraps this HTTP API). Exposes a
5
+ * narrow, token-authed surface so a same-machine script/tool can reset a
6
+ * stuck scheduler job or list jobs without hand-editing queue.json from
7
+ * outside the running app (which would race the in-process mutate() queue
8
+ * — see scheduler.cjs's serialized-mutation comment).
9
+ *
10
+ * Security posture:
11
+ * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
12
+ * off-box.
13
+ * - Bearer token, regenerated every app boot, written to
14
+ * ~/.claude/session-manager/admin-api.json with 0600 perms.
15
+ * - Token compared with crypto.timingSafeEqual to avoid a timing
16
+ * side-channel on the comparison itself.
17
+ * - Only two routes: reset-job (one narrow mutation) and jobs (read-only
18
+ * list). writePrd/pause/resume are intentionally NOT exposed here.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const http = require('node:http');
24
+ const os = require('node:os');
25
+ const path = require('node:path');
26
+ const crypto = require('node:crypto');
27
+ const fsp = require('node:fs/promises');
28
+ const config = require('./config.cjs');
29
+
30
+ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
31
+
32
+ function timingSafeEqualStrings(a, b) {
33
+ const bufA = Buffer.from(String(a ?? ''));
34
+ const bufB = Buffer.from(String(b ?? ''));
35
+ if (bufA.length !== bufB.length) {
36
+ // Compare against a same-length buffer anyway so the failure path still
37
+ // takes constant time relative to the (fixed) token length, rather than
38
+ // returning early on a length mismatch.
39
+ crypto.timingSafeEqual(bufA, bufA);
40
+ return false;
41
+ }
42
+ return crypto.timingSafeEqual(bufA, bufB);
43
+ }
44
+
45
+ function readBody(req, maxBytes = 1024 * 1024) {
46
+ return new Promise((resolve, reject) => {
47
+ let total = 0;
48
+ const chunks = [];
49
+ req.on('data', (chunk) => {
50
+ total += chunk.length;
51
+ if (total > maxBytes) {
52
+ reject(new Error('body too large'));
53
+ req.destroy();
54
+ return;
55
+ }
56
+ chunks.push(chunk);
57
+ });
58
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
59
+ req.on('error', reject);
60
+ });
61
+ }
62
+
63
+ function sendJson(res, status, obj) {
64
+ const body = JSON.stringify(obj);
65
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
66
+ res.end(body);
67
+ }
68
+
69
+ /**
70
+ * Create the admin HTTP server. `remote` is scheduler.cjs's remote object
71
+ * (injected, not required directly, so this module stays testable without
72
+ * booting Electron).
73
+ */
74
+ function createAdminServer(remote) {
75
+ let server = null;
76
+ let token = null;
77
+
78
+ async function ensureToken() {
79
+ token = crypto.randomBytes(32).toString('hex');
80
+ await config.writeJson(TOKEN_PATH, { port: null, token });
81
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
82
+ return token;
83
+ }
84
+
85
+ async function persistPort(port) {
86
+ await config.writeJson(TOKEN_PATH, { port, token });
87
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
88
+ }
89
+
90
+ function authorized(req) {
91
+ const header = req.headers.authorization || '';
92
+ const match = /^Bearer (.+)$/.exec(header);
93
+ if (!match) return false;
94
+ return timingSafeEqualStrings(match[1], token);
95
+ }
96
+
97
+ async function handleRequest(req, res) {
98
+ if (!authorized(req)) {
99
+ sendJson(res, 401, { ok: false, error: 'unauthorized' });
100
+ return;
101
+ }
102
+ try {
103
+ if (req.method === 'GET' && req.url === '/admin/scheduler/jobs') {
104
+ const jobs = await remote.listJobs();
105
+ sendJson(res, 200, jobs);
106
+ return;
107
+ }
108
+ if (req.method === 'POST' && req.url === '/admin/scheduler/reset-job') {
109
+ const raw = await readBody(req);
110
+ let parsed;
111
+ try {
112
+ parsed = raw ? JSON.parse(raw) : {};
113
+ } catch {
114
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
115
+ return;
116
+ }
117
+ const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
118
+ if (!slug) {
119
+ sendJson(res, 400, { ok: false, error: 'missing slug' });
120
+ return;
121
+ }
122
+ const result = await remote.resetJob(slug);
123
+ sendJson(res, 200, result);
124
+ return;
125
+ }
126
+ sendJson(res, 404, { ok: false, error: 'not found' });
127
+ } catch (e) {
128
+ sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
129
+ }
130
+ }
131
+
132
+ async function start() {
133
+ await ensureToken();
134
+ server = http.createServer((req, res) => {
135
+ handleRequest(req, res).catch(() => {
136
+ try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
137
+ });
138
+ });
139
+ await new Promise((resolve, reject) => {
140
+ server.once('error', reject);
141
+ server.listen(0, '127.0.0.1', resolve);
142
+ });
143
+ const { port } = server.address();
144
+ await persistPort(port);
145
+ return { port, token };
146
+ }
147
+
148
+ async function stop() {
149
+ if (!server) return;
150
+ await new Promise((resolve) => server.close(() => resolve()));
151
+ server = null;
152
+ }
153
+
154
+ return { start, stop, get token() { return token; }, get server() { return server; } };
155
+ }
156
+
157
+ module.exports = { createAdminServer, TOKEN_PATH };