claude-code-session-manager 0.40.3 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-Cr-O75Yg.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-QRU8uTeq.js → index-CV9TNjxX.js} +631 -624
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
- package/scripts/lib/watchdogHelpers.cjs +5 -5
- package/scripts/mint-epic.cjs +28 -0
- package/scripts/propose-epic.cjs +55 -0
- package/src/main/__tests__/epicMint.test.cjs +85 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +87 -0
- package/src/main/__tests__/prdCreate.test.cjs +124 -2
- package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
- package/src/main/browserView.cjs +1 -1
- package/src/main/chatRunner.cjs +24 -47
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +59 -11
- package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
- package/src/main/lib/epicMint.cjs +133 -59
- package/src/main/lib/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +169 -0
- package/src/main/lib/prdCreate.cjs +39 -1
- package/src/main/lib/prdLocations.cjs +21 -0
- package/src/main/lib/projectBriefCore.cjs +70 -4
- package/src/main/lib/queueStore.cjs +3 -0
- package/src/main/lib/rcaFeedbackHook.cjs +41 -55
- package/src/main/logs.cjs +19 -1
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +15 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +158 -62
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +83 -4
- package/src/preload/index.cjs +26 -5
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-notify-originating-tab-transcript.test.cjs — unit tests for the
|
|
3
|
+
* PRD 863 addition to notifyOriginatingTab: persisting the job's real
|
|
4
|
+
* result text (read from the run's log) to the durable per-Epic transcript
|
|
5
|
+
* store, independent of the existing short-status-chip notification.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, vi } from 'vitest';
|
|
13
|
+
const { notifyOriginatingTab, extractResultTextFromLog } = require('../scheduler.cjs');
|
|
14
|
+
|
|
15
|
+
test('extractResultTextFromLog returns null for a missing log path', () => {
|
|
16
|
+
expect(extractResultTextFromLog(null)).toBeNull();
|
|
17
|
+
expect(extractResultTextFromLog('/does/not/exist.log')).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('appends the job result text to the transcript store when sourcePromptId is known', async () => {
|
|
21
|
+
const appendTranscriptTurn = vi.fn(async () => true);
|
|
22
|
+
const readResultFromLog = vi.fn(() => 'the real agent result text');
|
|
23
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
24
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
|
|
25
|
+
|
|
26
|
+
await notifyOriginatingTab(
|
|
27
|
+
{ slug: '863-transcript', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
|
|
28
|
+
{ parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(appendTranscriptTurn).toHaveBeenCalledTimes(1);
|
|
32
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith('/some/cwd', 'psess-abc', {
|
|
33
|
+
role: 'assistant',
|
|
34
|
+
text: 'the real agent result text',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('falls back to the short status message when the run log has no result text', async () => {
|
|
39
|
+
const appendTranscriptTurn = vi.fn(async () => true);
|
|
40
|
+
const readResultFromLog = vi.fn(() => null);
|
|
41
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
42
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
|
|
43
|
+
|
|
44
|
+
await notifyOriginatingTab(
|
|
45
|
+
{ slug: '863-fallback', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
|
|
46
|
+
{ parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith(
|
|
50
|
+
'/some/cwd',
|
|
51
|
+
'psess-abc',
|
|
52
|
+
expect.objectContaining({ text: expect.stringContaining('863-fallback') }),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a throwing transcript append never breaks the existing notification path', async () => {
|
|
57
|
+
const appendTranscriptTurn = vi.fn(async () => {
|
|
58
|
+
throw new Error('disk full');
|
|
59
|
+
});
|
|
60
|
+
const readResultFromLog = vi.fn(() => 'result text');
|
|
61
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
62
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
notifyOriginatingTab(
|
|
66
|
+
{ slug: '863-safe', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
|
|
67
|
+
{ parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
|
|
68
|
+
),
|
|
69
|
+
).resolves.toBeUndefined();
|
|
70
|
+
|
|
71
|
+
expect(appendResponseEvent).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('no cwd on the job skips the transcript append but does not throw', async () => {
|
|
75
|
+
const appendTranscriptTurn = vi.fn(async () => true);
|
|
76
|
+
const readResultFromLog = vi.fn(() => 'result text');
|
|
77
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
78
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
|
|
79
|
+
|
|
80
|
+
await notifyOriginatingTab(
|
|
81
|
+
{ slug: '863-nocwd', status: 'completed', cwd: null, runId: 'run-1' },
|
|
82
|
+
{ parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(appendTranscriptTurn).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
@@ -150,6 +150,27 @@ test('falls back to the tab-external-ticket path when the PRD has no sourcePromp
|
|
|
150
150
|
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
// PRD 854: a PRD dispatched straight from an Epic's own composer
|
|
154
|
+
// (dispatchPromptSessionToPrd) never sets sourceTabId — only sourcePromptId.
|
|
155
|
+
// If the Epic has since gone completed (appendResponseEvent returns false),
|
|
156
|
+
// the fallback must still target sourcePromptId itself as the chat:external-
|
|
157
|
+
// send id, since the renderer now resolves that id against known Epics too.
|
|
158
|
+
test('falls back to sourcePromptId as the external-send target when sourceTabId is absent', async () => {
|
|
159
|
+
const sendPrompt = vi.fn();
|
|
160
|
+
const appendResponseEvent = vi.fn(async () => false);
|
|
161
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'epic-1', sourceTabId: null }));
|
|
162
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
163
|
+
|
|
164
|
+
await notifyOriginatingTab(
|
|
165
|
+
{ slug: '854-notify', status: 'completed', cwd: '/some/cwd' },
|
|
166
|
+
{ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(appendResponseEvent).toHaveBeenCalled();
|
|
170
|
+
expect(loadSessions).not.toHaveBeenCalled();
|
|
171
|
+
expect(sendPrompt).toHaveBeenCalledWith('epic-1', expect.stringContaining('854-notify'));
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
test('falls back to the tab-external-ticket path when appendResponseEvent itself throws', async () => {
|
|
154
175
|
const sendPrompt = vi.fn();
|
|
155
176
|
const appendResponseEvent = vi.fn(async () => { throw new Error('disk error'); });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-writeprd-epic-rollback.test.cjs — PRD 851: when remote.writePrd
|
|
3
|
+
* mints a brand-new Epic via ensureEpic (epicMint.cjs) but the PRD file write
|
|
4
|
+
* itself then fails, cleanupEmptyMintedEpic must roll back BOTH the on-disk
|
|
5
|
+
* empty epic dir AND the Epic's active-index.json entry (sessions/events) —
|
|
6
|
+
* otherwise an orphaned seed-prompt-only Epic lingers forever in the Epics
|
|
7
|
+
* nav with no PRDs and no cleanup path.
|
|
8
|
+
*
|
|
9
|
+
* The rollback must only fire for a call that minted a FRESH Epic
|
|
10
|
+
* (ensureEpic's `created: true`); a PRD write that joins an existing Epic
|
|
11
|
+
* and then fails must leave that Epic's history untouched.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, afterEach } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { remote } = require('../scheduler.cjs');
|
|
23
|
+
const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
24
|
+
|
|
25
|
+
const tmpDirs = [];
|
|
26
|
+
|
|
27
|
+
function makeFixtureCwd() {
|
|
28
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-writeprd-epic-rollback-'));
|
|
29
|
+
tmpDirs.push(cwd);
|
|
30
|
+
return cwd;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
while (tmpDirs.length) {
|
|
35
|
+
const dir = tmpDirs.pop();
|
|
36
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// A slug that escapes safeSlugPathIn's containment check, tripping the
|
|
41
|
+
// "invalid slug" branch of writePrd (scheduler.cjs ~4188-4191) after
|
|
42
|
+
// ensureEpic has already minted+persisted the Epic.
|
|
43
|
+
const ESCAPING_SLUG = '../escape-attempt';
|
|
44
|
+
|
|
45
|
+
test('writePrd: invalid-slug failure after a fresh Epic mint leaves zero residual active-index.json entries', async () => {
|
|
46
|
+
const cwd = makeFixtureCwd();
|
|
47
|
+
const body = '---\ntitle: Freshly minted epic goal\n---\n\nbody text\n';
|
|
48
|
+
|
|
49
|
+
const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
|
|
50
|
+
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
expect(result.error).toBe('invalid slug');
|
|
53
|
+
|
|
54
|
+
const index = readActiveIndex(cwd);
|
|
55
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
56
|
+
expect(Object.keys(index.events)).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves that Epic untouched', async () => {
|
|
60
|
+
const cwd = makeFixtureCwd();
|
|
61
|
+
|
|
62
|
+
const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal' });
|
|
63
|
+
expect(preexisting.created).toBe(true);
|
|
64
|
+
|
|
65
|
+
const beforeIndex = readActiveIndex(cwd);
|
|
66
|
+
expect(Object.keys(beforeIndex.sessions)).toEqual([preexisting.epicId]);
|
|
67
|
+
|
|
68
|
+
const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
|
|
69
|
+
const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
|
|
70
|
+
|
|
71
|
+
expect(result.ok).toBe(false);
|
|
72
|
+
expect(result.error).toBe('invalid slug');
|
|
73
|
+
|
|
74
|
+
const afterIndex = readActiveIndex(cwd);
|
|
75
|
+
expect(Object.keys(afterIndex.sessions)).toEqual([preexisting.epicId]);
|
|
76
|
+
expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
|
|
77
|
+
expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
|
|
78
|
+
});
|
package/src/main/browserView.cjs
CHANGED
|
@@ -836,7 +836,7 @@ function registerBrowserView({ mainWindow, ipcMain }) {
|
|
|
836
836
|
ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
|
|
837
837
|
ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
|
|
838
838
|
const { writeBinaryAtomic } = require('./config.cjs');
|
|
839
|
-
return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'))
|
|
839
|
+
return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'), { writer: payload.writer })
|
|
840
840
|
.then(() => ({ ok: true }))
|
|
841
841
|
.catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
|
|
842
842
|
}));
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -18,13 +18,11 @@
|
|
|
18
18
|
* Public surface:
|
|
19
19
|
* run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
|
|
20
20
|
* Both silent (PRD 470) and manual runs go through the same CONCURRENCY_CAP FIFO lane.
|
|
21
|
-
* Silent runs additionally suppress the six turn-affecting broadcasts + recordExchange
|
|
22
|
-
* (see probeContextUsage below).
|
|
21
|
+
* Silent runs additionally suppress the six turn-affecting broadcasts + recordExchange.
|
|
23
22
|
* cancel(tabId): Promise<void> — resolves once the cancelled run fully settles
|
|
24
23
|
* parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
|
|
25
24
|
* parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
|
|
26
25
|
* — pure parser for `/context`
|
|
27
|
-
* probeContextUsage({ tabId, sessionId, cwd }): void — fire-and-forget silent `/context` probe
|
|
28
26
|
* STOP_SENTINEL: string — exported for tests
|
|
29
27
|
* __setExecutor(fn): void — test seam
|
|
30
28
|
*
|
|
@@ -42,8 +40,6 @@
|
|
|
42
40
|
* landed before verifying/retrying
|
|
43
41
|
* chat:run:notice { tabId, sessionId, message } — informational, not terminal
|
|
44
42
|
* (also fires at 80% of the kill ceiling as a wrap-up nudge)
|
|
45
|
-
* chat:context-usage { tabId, sessionId, usedTokens, totalTokens, usedPct, categories }
|
|
46
|
-
* — result of a silent `/context` probe
|
|
47
43
|
*/
|
|
48
44
|
|
|
49
45
|
const { spawn } = require('node:child_process');
|
|
@@ -55,6 +51,7 @@ const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
|
|
|
55
51
|
const { extractJson } = require('./lib/extractJson.cjs');
|
|
56
52
|
const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
57
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
58
55
|
|
|
59
56
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
60
57
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -184,41 +181,6 @@ function parseContextUsageMarkdown(text) {
|
|
|
184
181
|
return { usedTokens, totalTokens, usedPct, categories };
|
|
185
182
|
}
|
|
186
183
|
|
|
187
|
-
/**
|
|
188
|
-
* Fire-and-forget silent probe of the resumed session's context usage. Reuses
|
|
189
|
-
* the normal `run()` queue/lane (resume: true, silent: true) so it can never
|
|
190
|
-
* race a real chat run against the same `--resume sessionId`. Broadcasts
|
|
191
|
-
* `chat:context-usage` on success; logs (never throws/broadcasts) on a parse
|
|
192
|
-
* failure.
|
|
193
|
-
*
|
|
194
|
-
* @param {{ tabId: string, sessionId: string, cwd: string }} params
|
|
195
|
-
*/
|
|
196
|
-
function probeContextUsage({ tabId, sessionId, cwd }) {
|
|
197
|
-
run({
|
|
198
|
-
tabId,
|
|
199
|
-
sessionId,
|
|
200
|
-
prompt: '/context',
|
|
201
|
-
cwd,
|
|
202
|
-
resume: true,
|
|
203
|
-
silent: true,
|
|
204
|
-
onSilentResult: (text) => {
|
|
205
|
-
const parsed = parseContextUsageMarkdown(text);
|
|
206
|
-
if (!parsed) {
|
|
207
|
-
console.error('[chatRunner] parseContextUsageMarkdown failed to parse /context probe output');
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
broadcast('chat:context-usage', {
|
|
211
|
-
tabId,
|
|
212
|
-
sessionId,
|
|
213
|
-
usedTokens: parsed.usedTokens,
|
|
214
|
-
totalTokens: parsed.totalTokens,
|
|
215
|
-
usedPct: parsed.usedPct,
|
|
216
|
-
categories: parsed.categories,
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
184
|
// ─── MCP consent-denial detection ──────────────────────────────────────────
|
|
223
185
|
// Best-effort substring heuristic over known CLI phrasing — NOT a documented
|
|
224
186
|
// stream-json schema field. Confirmed by extracting strings from the compiled
|
|
@@ -509,6 +471,19 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
509
471
|
const emitTerminal = (channel, payload) => {
|
|
510
472
|
if (terminalSent) return;
|
|
511
473
|
terminalSent = true;
|
|
474
|
+
// Every terminal error for this tab, logged once, regardless of the
|
|
475
|
+
// `silent` gate below — a probe run's failure is still real signal for
|
|
476
|
+
// tracing even though it never reaches the visible chat UI.
|
|
477
|
+
if (channel === 'chat:run:error') {
|
|
478
|
+
opsErrorLog.appendError({
|
|
479
|
+
cwd,
|
|
480
|
+
scope: 'chatRunner',
|
|
481
|
+
tabId,
|
|
482
|
+
tags: silent ? ['silent-probe'] : [],
|
|
483
|
+
message: payload?.message ?? 'chat run failed',
|
|
484
|
+
meta: { sessionId, silent },
|
|
485
|
+
});
|
|
486
|
+
}
|
|
512
487
|
// Silent (probe) runs still need this bookkeeping so the lane frees
|
|
513
488
|
// correctly, but must never surface on the six turn-affecting channels.
|
|
514
489
|
if (silent) return;
|
|
@@ -534,7 +509,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
534
509
|
};
|
|
535
510
|
|
|
536
511
|
const claudeBin = resolveClaudeBin();
|
|
537
|
-
|
|
512
|
+
// Carried down to any MCP server this run spawns (e.g.
|
|
513
|
+
// scripts/scheduler-mcp-server.cjs, inherited via the claude CLI's own
|
|
514
|
+
// child env) so scheduler_create_prd can auto-resolve sourcePromptId
|
|
515
|
+
// when the model forgets to pass it — see prdCreate.cjs's
|
|
516
|
+
// resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
|
|
517
|
+
// claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
|
|
518
|
+
// 1:1); for a non-Epic tab, resolution below simply finds no match.
|
|
519
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId });
|
|
538
520
|
|
|
539
521
|
// Prepend the stop-signal protocol instruction and the chat-mode truth
|
|
540
522
|
// instruction to every prompt
|
|
@@ -686,7 +668,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
686
668
|
if (event.subtype === 'success') {
|
|
687
669
|
if (silent) {
|
|
688
670
|
// Silent probe: no turn broadcast, no recordExchange — just hand
|
|
689
|
-
// the final text to whoever enqueued the probe
|
|
671
|
+
// the final text to whoever enqueued the probe.
|
|
690
672
|
emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
691
673
|
if (typeof onSilentResult === 'function') onSilentResult(text);
|
|
692
674
|
} else {
|
|
@@ -888,10 +870,6 @@ function registerChatHandlers() {
|
|
|
888
870
|
await cancel(tabId);
|
|
889
871
|
});
|
|
890
872
|
|
|
891
|
-
ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
|
|
892
|
-
probeContextUsage({ tabId, sessionId, cwd });
|
|
893
|
-
return { ok: true };
|
|
894
|
-
}));
|
|
895
873
|
}
|
|
896
874
|
|
|
897
875
|
module.exports = {
|
|
@@ -903,7 +881,6 @@ module.exports = {
|
|
|
903
881
|
parseStopSignal,
|
|
904
882
|
splitStopSignal,
|
|
905
883
|
parseContextUsageMarkdown,
|
|
906
|
-
probeContextUsage,
|
|
907
884
|
STOP_SENTINEL,
|
|
908
885
|
CHAT_MODE_TRUTH_INSTRUCTION,
|
|
909
886
|
KILL_CEILING_MS,
|
package/src/main/config.cjs
CHANGED
|
@@ -19,6 +19,7 @@ const fs = require('node:fs');
|
|
|
19
19
|
const fsp = require('node:fs/promises');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const os = require('node:os');
|
|
22
|
+
const { assertOpsWrite } = require('./lib/opsOwnership.cjs');
|
|
22
23
|
const chokidar = require('chokidar');
|
|
23
24
|
const logs = require('./logs.cjs');
|
|
24
25
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
@@ -219,6 +220,7 @@ async function readText(abs) {
|
|
|
219
220
|
async function writeTextAtomic(abs, text, opts = {}) {
|
|
220
221
|
const real = validatePath(expandHome(abs));
|
|
221
222
|
validateWrite(real);
|
|
223
|
+
assertOpsWrite(real, opts.writer);
|
|
222
224
|
const dir = path.dirname(real);
|
|
223
225
|
await fsp.mkdir(dir, { recursive: true });
|
|
224
226
|
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -242,9 +244,10 @@ async function writeTextAtomic(abs, text, opts = {}) {
|
|
|
242
244
|
* Binary-safe sibling of writeTextAtomic — same validate + tmp+rename flow,
|
|
243
245
|
* for callers (screenshot capture) whose payload is a Buffer, not utf8 text.
|
|
244
246
|
*/
|
|
245
|
-
async function writeBinaryAtomic(abs, buffer) {
|
|
247
|
+
async function writeBinaryAtomic(abs, buffer, opts = {}) {
|
|
246
248
|
const real = validatePath(expandHome(abs));
|
|
247
249
|
validateWrite(real);
|
|
250
|
+
assertOpsWrite(real, opts.writer);
|
|
248
251
|
const dir = path.dirname(real);
|
|
249
252
|
await fsp.mkdir(dir, { recursive: true });
|
|
250
253
|
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -259,9 +262,9 @@ async function writeBinaryAtomic(abs, buffer) {
|
|
|
259
262
|
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
260
263
|
}
|
|
261
264
|
|
|
262
|
-
async function writeJson(abs, data) {
|
|
265
|
+
async function writeJson(abs, data, opts = {}) {
|
|
263
266
|
const pretty = JSON.stringify(data, null, 2) + '\n';
|
|
264
|
-
return writeTextAtomic(abs, pretty);
|
|
267
|
+
return writeTextAtomic(abs, pretty, opts);
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
/**
|
|
@@ -269,9 +272,10 @@ async function writeJson(abs, data) {
|
|
|
269
272
|
* an event-loop yield would deadlock the caller. Use writeJson/writeTextAtomic
|
|
270
273
|
* for everything else.
|
|
271
274
|
*/
|
|
272
|
-
function writeJsonSync(abs, data) {
|
|
275
|
+
function writeJsonSync(abs, data, opts = {}) {
|
|
273
276
|
const real = validatePath(expandHome(abs));
|
|
274
277
|
validateWrite(real);
|
|
278
|
+
assertOpsWrite(real, opts.writer);
|
|
275
279
|
const dir = path.dirname(real);
|
|
276
280
|
fs.mkdirSync(dir, { recursive: true });
|
|
277
281
|
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -432,8 +436,10 @@ function registerConfigHandlers() {
|
|
|
432
436
|
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
433
437
|
ipcMain.handle('config:read-json', v(s.configPath, ({ path: p }) => readJson(p)));
|
|
434
438
|
ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
|
|
435
|
-
|
|
436
|
-
|
|
439
|
+
// `writer` is the renderer's declared owner id for the single-writer law
|
|
440
|
+
// (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
|
|
441
|
+
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => writeJson(p, data, { writer })));
|
|
442
|
+
ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
|
|
437
443
|
ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
|
|
438
444
|
ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
|
|
439
445
|
ipcMain.handle('config:parse-imports', v(s.configParseImports, async ({ path: p }) => {
|
package/src/main/docEdit.cjs
CHANGED
|
@@ -169,13 +169,12 @@ const SESSION_EDIT_TIMEOUT_MS = 120_000;
|
|
|
169
169
|
/**
|
|
170
170
|
* Route a doc edit into an already-open, currently-idle chat session (PRD 680)
|
|
171
171
|
* instead of an isolated one-shot `claude -p`. Reuses chatRunner.cjs's silent/
|
|
172
|
-
* resumed run path verbatim
|
|
173
|
-
*
|
|
172
|
+
* resumed run path verbatim so the edit lands as one more turn of the
|
|
173
|
+
* session's own transcript.
|
|
174
174
|
*
|
|
175
175
|
* Fire-and-forget: the result reaches the renderer via a `docedit:session-result`
|
|
176
176
|
* broadcast (tagged with `requestId`) once chatRunner's onSilentResult fires, or
|
|
177
|
-
* once SESSION_EDIT_TIMEOUT_MS elapses without one (see above)
|
|
178
|
-
* indirection probeContextUsage uses for `chat:context-usage`, plus the bound.
|
|
177
|
+
* once SESSION_EDIT_TIMEOUT_MS elapses without one (see above).
|
|
179
178
|
*
|
|
180
179
|
* @param {{ tabId: string, sessionId: string, cwd: string, before: string, instruction: string, documentText?: string, requestId: string }} params
|
|
181
180
|
*/
|
package/src/main/index.cjs
CHANGED
|
@@ -29,6 +29,7 @@ const scheduler = require('./scheduler.cjs');
|
|
|
29
29
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
30
30
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
31
31
|
const chatRunner = require('./chatRunner.cjs');
|
|
32
|
+
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
32
33
|
const adminHttp = createAdminHttp();
|
|
33
34
|
scheduler.registerAdminRoutes(adminHttp);
|
|
34
35
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
@@ -55,6 +56,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
|
|
|
55
56
|
const memoryTool = require('./memoryTool.cjs');
|
|
56
57
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
57
58
|
const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
59
|
+
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
58
60
|
const agentMemory = require('./agentMemory.cjs');
|
|
59
61
|
const git = require('./git.cjs');
|
|
60
62
|
const filesIpc = require('./files.cjs');
|
|
@@ -307,6 +309,7 @@ async function rebootApp() {
|
|
|
307
309
|
watchers.attachWindow(mainWindow);
|
|
308
310
|
pluginInstall.attachWindow(mainWindow);
|
|
309
311
|
chatRunner.attachWindow(mainWindow);
|
|
312
|
+
promptSessionEvents.attachWindow(mainWindow);
|
|
310
313
|
attachDocEditWindow(mainWindow);
|
|
311
314
|
rebooting = false;
|
|
312
315
|
return;
|
|
@@ -772,6 +775,14 @@ pluginInstall.registerPluginInstallHandlers();
|
|
|
772
775
|
memoryTool.registerMemoryHandlers();
|
|
773
776
|
registerMemoryAggregateIpc();
|
|
774
777
|
registerProjectBriefIpc();
|
|
778
|
+
ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
|
|
779
|
+
const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
|
|
780
|
+
return { ok };
|
|
781
|
+
}));
|
|
782
|
+
ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTranscriptRead, async ({ cwd, epicId, limit }) => {
|
|
783
|
+
const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
|
|
784
|
+
return { turns };
|
|
785
|
+
}));
|
|
775
786
|
agentMemory.registerAgentMemoryHandlers();
|
|
776
787
|
git.register(ipcMain);
|
|
777
788
|
filesIpc.registerFilesHandlers();
|
|
@@ -1092,6 +1103,7 @@ app.whenReady().then(async () => {
|
|
|
1092
1103
|
pluginInstall.attachWindow(mainWindow);
|
|
1093
1104
|
webRemote.attachWindow(mainWindow);
|
|
1094
1105
|
chatRunner.attachWindow(mainWindow);
|
|
1106
|
+
promptSessionEvents.attachWindow(mainWindow);
|
|
1095
1107
|
attachDocEditWindow(mainWindow);
|
|
1096
1108
|
// Scheduler ownership (PRD 834): exactly one instance machine-wide runs
|
|
1097
1109
|
// scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -118,6 +118,8 @@ const browserSaveRecording = z.object({
|
|
|
118
118
|
const browserSaveBinary = z.object({
|
|
119
119
|
path: z.string().min(1).max(4096),
|
|
120
120
|
base64: z.string().min(1).max(50_000_000),
|
|
121
|
+
// Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
|
|
122
|
+
writer: z.string().min(1).max(64).optional(),
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
// PRD 410: replay a recorded step list against a live view. The renderer
|
|
@@ -183,14 +185,20 @@ const transcriptUsageFor = z.object({
|
|
|
183
185
|
// ──────────────────────────────────────────── Config
|
|
184
186
|
const configPath = z.object({ path: z.string().min(1).max(4096) });
|
|
185
187
|
|
|
188
|
+
// `writer` carries the renderer's declared owner id for the single-writer law
|
|
189
|
+
// (lib/opsOwnership.cjs). Optional on the wire because most writes target
|
|
190
|
+
// ~/.claude, not a project ops root; writes INSIDE the ops root are refused
|
|
191
|
+
// when it is absent or wrong.
|
|
186
192
|
const configWriteJson = z.object({
|
|
187
193
|
path: z.string().min(1).max(4096),
|
|
188
194
|
data: z.unknown(),
|
|
195
|
+
writer: z.string().min(1).max(64).optional(),
|
|
189
196
|
});
|
|
190
197
|
|
|
191
198
|
const configWriteText = z.object({
|
|
192
199
|
path: z.string().min(1).max(4096),
|
|
193
200
|
text: z.string(),
|
|
201
|
+
writer: z.string().min(1).max(64).optional(),
|
|
194
202
|
});
|
|
195
203
|
|
|
196
204
|
const configListDir = z.object({
|
|
@@ -284,15 +292,25 @@ const schedulerCreatePrd = z.object({
|
|
|
284
292
|
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
285
293
|
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
286
294
|
dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
295
|
+
// An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
|
|
296
|
+
// ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
|
|
297
|
+
// sessions key literally equals this value; the renderer only ever sends
|
|
298
|
+
// PromptTicket.promptSessionId here (see state/chat.ts), never
|
|
299
|
+
// PromptTicket.id — those are distinct fields. Same newline-injection
|
|
300
|
+
// guard as title/cwd since it also becomes a frontmatter value.
|
|
291
301
|
sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
292
302
|
// Originating tab id (PRD 761) — used at job completion to route a status
|
|
293
303
|
// prompt back into the chat tab that queued this PRD, via
|
|
294
304
|
// enqueueExternalPrompt (PRD 753). Same newline-injection guard.
|
|
295
305
|
sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
306
|
+
// The calling MCP process's own claude session id (scheduler-mcp-server.cjs
|
|
307
|
+
// forwards its inherited SM_CHAT_SESSION_ID env var here when the model
|
|
308
|
+
// omitted sourcePromptId) — never written to frontmatter itself; prdCreate.cjs's
|
|
309
|
+
// createPrd() resolves it against active-index.json to fill sourcePromptId
|
|
310
|
+
// when it matches an existing Epic's claudeSessionId, so a PRD queued from
|
|
311
|
+
// within an Epic's own chat session joins that Epic even if the tool call
|
|
312
|
+
// forgot to pass sourcePromptId explicitly.
|
|
313
|
+
originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
296
314
|
// User-selected Feature/Bug tag (PRD 774) carried from the originating
|
|
297
315
|
// PromptTicket — deterministic, never LLM-classified.
|
|
298
316
|
tag: z.enum(['feature', 'bug', 'discussion']).optional(),
|
|
@@ -425,6 +443,40 @@ const projectBriefSetPin = z.object({
|
|
|
425
443
|
block: PROJECT_BRIEF_BLOCK,
|
|
426
444
|
pinned: z.boolean(),
|
|
427
445
|
}).strict();
|
|
446
|
+
// Hand-edit patch. Only bounds the wire shape (sizes + which keys exist);
|
|
447
|
+
// per-field shape is enforced by projectBriefCore.validateUpdateField so the
|
|
448
|
+
// rule lives in one unit-tested place. At least one key is required.
|
|
449
|
+
const PROJECT_BRIEF_TEXT = z.string().max(20_000);
|
|
450
|
+
const projectBriefUpdate = z.object({
|
|
451
|
+
cwd: z.string().min(1).max(4096),
|
|
452
|
+
patch: z.object({
|
|
453
|
+
purpose: PROJECT_BRIEF_TEXT.optional(),
|
|
454
|
+
what: z.array(PROJECT_BRIEF_TEXT).max(50).optional(),
|
|
455
|
+
conventions: z.array(PROJECT_BRIEF_TEXT).max(100).optional(),
|
|
456
|
+
areas: z.array(z.record(z.unknown())).max(100).optional(),
|
|
457
|
+
scope: z.array(z.record(z.unknown())).max(200).optional(),
|
|
458
|
+
}).strict().refine((p) => Object.keys(p).length > 0, { message: 'patch is empty' }),
|
|
459
|
+
}).strict();
|
|
460
|
+
|
|
461
|
+
// ──────────────────────────────────────────── Prompt session transcript
|
|
462
|
+
// (durable full-text turn store, PRD 863). cwd/epicId are only bounded for
|
|
463
|
+
// wire shape here — real path safety is config.cjs's validatePath/
|
|
464
|
+
// validateWrite inside promptSessionTranscript.cjs.
|
|
465
|
+
const PROMPT_SESSION_EPIC_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
466
|
+
const PROMPT_SESSION_TRANSCRIPT_ROLE = z.enum(['user', 'assistant']);
|
|
467
|
+
const promptSessionTranscriptAppend = z.object({
|
|
468
|
+
cwd: z.string().min(1).max(4096),
|
|
469
|
+
epicId: z.string().regex(PROMPT_SESSION_EPIC_ID_RE),
|
|
470
|
+
role: PROMPT_SESSION_TRANSCRIPT_ROLE,
|
|
471
|
+
text: z.string().max(2_000_000),
|
|
472
|
+
at: z.string().max(64).optional(),
|
|
473
|
+
eventId: z.string().max(128).optional(),
|
|
474
|
+
}).strict();
|
|
475
|
+
const promptSessionTranscriptRead = z.object({
|
|
476
|
+
cwd: z.string().min(1).max(4096),
|
|
477
|
+
epicId: z.string().regex(PROMPT_SESSION_EPIC_ID_RE),
|
|
478
|
+
limit: z.number().int().positive().max(10_000).optional(),
|
|
479
|
+
}).strict();
|
|
428
480
|
|
|
429
481
|
// ──────────────────────────────────────────── Per-subagent memory
|
|
430
482
|
// Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
|
|
@@ -516,12 +568,6 @@ const chatCancel = z.object({
|
|
|
516
568
|
tabId: z.string().min(1).max(128),
|
|
517
569
|
});
|
|
518
570
|
|
|
519
|
-
const chatProbeContext = z.object({
|
|
520
|
-
tabId: z.string().min(1).max(128),
|
|
521
|
-
sessionId: z.string().min(1).max(128),
|
|
522
|
-
cwd: z.string().min(1).max(4096),
|
|
523
|
-
});
|
|
524
|
-
|
|
525
571
|
const chatClassifyTicket = z.object({
|
|
526
572
|
text: z.string().min(1).refine(
|
|
527
573
|
(s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
|
|
@@ -817,6 +863,9 @@ module.exports = {
|
|
|
817
863
|
memoryStale,
|
|
818
864
|
projectBriefCwd,
|
|
819
865
|
projectBriefSetPin,
|
|
866
|
+
projectBriefUpdate,
|
|
867
|
+
promptSessionTranscriptAppend,
|
|
868
|
+
promptSessionTranscriptRead,
|
|
820
869
|
agentMemoryList,
|
|
821
870
|
agentMemoryGet,
|
|
822
871
|
agentMemorySet,
|
|
@@ -830,7 +879,6 @@ module.exports = {
|
|
|
830
879
|
docEditRunInSession,
|
|
831
880
|
chatRun,
|
|
832
881
|
chatCancel,
|
|
833
|
-
chatProbeContext,
|
|
834
882
|
chatClassifyTicket,
|
|
835
883
|
chatExternalSend,
|
|
836
884
|
exchangesList,
|