@onefl666/dsh-great-plan 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +99 -2
- package/README.zh-CN.md +100 -0
- package/client.actions.js +1031 -0
- package/client.js +378 -0
- package/client.model.js +232 -0
- package/client.panel.js +897 -0
- package/client.preset.js +127 -0
- package/client.settings.js +226 -0
- package/client.ui.js +534 -0
- package/cordis.patch.yml +3 -0
- package/host/apply.js +24 -0
- package/host/config.js +19 -0
- package/host/plan-compaction.js +96 -0
- package/host/plan-evidence.js +252 -0
- package/host/plan-range.js +32 -0
- package/icon.svg +19 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@onefl666/dsh-great-plan',
|
|
3
|
+
chunk: 'client.actions.js',
|
|
4
|
+
factory() {
|
|
5
|
+
function messageOf(error) {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Model-facing reminder appended to fresh-session execution when the budget switch is on. */
|
|
10
|
+
const UNLIMITED_BUDGET_REMINDER = 'Token budget: unlimited. Remember: we are DeepSeek, your usage is unlimited, and I am stress-testing you at the limit. Do not skimp on tokens — spend freely; you are very wealthy. Do not reduce investigation, tool calls, or necessary questions to save tokens; keep the final summary concise.';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Exact model-facing text native plan mode appends after an approved review.
|
|
14
|
+
*
|
|
15
|
+
* A fresh session has no `exit_plan_mode` tool result, so the plugin appends
|
|
16
|
+
* the same canonical handoff to its first message. Keep it byte-identical.
|
|
17
|
+
*/
|
|
18
|
+
const PLAN_APPROVED_HANDOFF = 'Plan approved — plan mode exited; carry out the plan starting with your next step.';
|
|
19
|
+
|
|
20
|
+
/** Fixed wrapper tags keep the model-facing prompt parseable across locales. */
|
|
21
|
+
const PLAN_MODE_INSTRUCTIONS_TAG = 'plan-mode-user-instructions';
|
|
22
|
+
const FRESH_SESSION_CONTEXT_TAG = 'fresh-session-context';
|
|
23
|
+
const INSTRUCTIONS_SEPARATOR = '\n\n---\n\n';
|
|
24
|
+
const PLAN_MODE_LOAD_PAGES = 8;
|
|
25
|
+
|
|
26
|
+
/** First-message size above which the clear-view preview warns the user. */
|
|
27
|
+
const FRESH_PROMPT_WARN_CHARS = 32000;
|
|
28
|
+
|
|
29
|
+
/** The plan summary shown in the execution dialog is capped at this many characters. */
|
|
30
|
+
const PLAN_PREVIEW_MAX_CHARS = 240;
|
|
31
|
+
|
|
32
|
+
const COMPACT_COMMAND = '/great-plan-compact';
|
|
33
|
+
const COMPACT_UNAVAILABLE_MESSAGE = 'The /great-plan-compact command is not available.';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Host command failure text mapped to the Client locale keys.
|
|
37
|
+
*
|
|
38
|
+
* The keys are the Host's exact user-visible static messages (see
|
|
39
|
+
* `host/plan-compaction.js` exports `USAGE`, `FAILURES`, and `MESSAGES`)
|
|
40
|
+
* plus the Client-side unavailable message. A cross-side test keeps both
|
|
41
|
+
* halves in sync; dynamic Host messages intentionally fall back to raw
|
|
42
|
+
* English.
|
|
43
|
+
*/
|
|
44
|
+
const COMPACT_MESSAGE_KEYS = {
|
|
45
|
+
'Usage: /great-plan-compact (no arguments)': 'compact.error.usage',
|
|
46
|
+
'Compaction is unavailable because another compaction is running.': 'compact.error.busy',
|
|
47
|
+
'The history selected for compaction changed during the attempt.': 'compact.error.changed',
|
|
48
|
+
'Compaction could not produce a useful summary.': 'compact.error.summary',
|
|
49
|
+
'Compaction did not finish cleanly.': 'compact.error.commit',
|
|
50
|
+
'Compaction finished, but the session could not be saved.': 'compact.error.persistence',
|
|
51
|
+
'Compaction was cancelled.': 'compact.error.cancelled',
|
|
52
|
+
'Plan state is unavailable in this composition.': 'compact.error.planStateUnavailable',
|
|
53
|
+
'Plan mode is not active; nothing to compact.': 'compact.error.planInactive',
|
|
54
|
+
'Compaction is unavailable because no compaction backend is composed.': 'compact.error.backendUnavailable',
|
|
55
|
+
'There is no pending plan review to compact.': 'compact.error.noReview',
|
|
56
|
+
'No compactable history exists before the pending plan.': 'compact.error.empty',
|
|
57
|
+
'The /great-plan-compact command is not available.': 'compact.error.unavailable',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Stable action failure codes; each one has a `t('error.<code>')` locale key. */
|
|
61
|
+
const ACTION_ERROR_CODES = [
|
|
62
|
+
'emptyPlan',
|
|
63
|
+
'reviewGone',
|
|
64
|
+
'createSessionFailed',
|
|
65
|
+
'modelApplyFailed',
|
|
66
|
+
'startSessionFailed',
|
|
67
|
+
'openSessionFailed',
|
|
68
|
+
'dismissFailed',
|
|
69
|
+
'cleanupFailed',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** English fallback messages for action failures; the Client localizes them. */
|
|
73
|
+
const ACTION_ERROR_MESSAGES = {
|
|
74
|
+
emptyPlan: 'The reviewed plan is empty.',
|
|
75
|
+
reviewGone: 'The plan review has ended.',
|
|
76
|
+
createSessionFailed: 'Creating the new session failed: {message}',
|
|
77
|
+
modelApplyFailed: 'Applying the model selection failed: {message}',
|
|
78
|
+
startSessionFailed: 'Starting execution in the new session failed: {message}',
|
|
79
|
+
openSessionFailed: 'The new session was created but could not be opened: {message}',
|
|
80
|
+
dismissFailed: 'Could not return to editing; please try again: {message}',
|
|
81
|
+
cleanupFailed: 'The unused new session could not be cleaned up: {message}',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Cached `Intl.DateTimeFormat` instances keyed by `<kind>|<zone>`. */
|
|
85
|
+
const INTL_FORMATTERS = new Map();
|
|
86
|
+
|
|
87
|
+
/** Fill `{name}` placeholders in one English template. */
|
|
88
|
+
function formatTemplate(template, params) {
|
|
89
|
+
if (params === null || typeof params !== 'object') return template;
|
|
90
|
+
return Object.entries(params).reduce(
|
|
91
|
+
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
|
92
|
+
template,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One user-facing action failure.
|
|
98
|
+
*
|
|
99
|
+
* The Client localizes it through `describeActionError`; `.message` keeps an
|
|
100
|
+
* English fallback for logs and for callers without a translator.
|
|
101
|
+
*/
|
|
102
|
+
class ActionError extends Error {
|
|
103
|
+
constructor(code, params = {}) {
|
|
104
|
+
const normalizedCode = typeof code === 'string' ? code : String(code);
|
|
105
|
+
const template = ACTION_ERROR_MESSAGES[normalizedCode];
|
|
106
|
+
super(template === undefined ? normalizedCode : formatTemplate(template, params));
|
|
107
|
+
this.name = 'ActionError';
|
|
108
|
+
this.code = normalizedCode;
|
|
109
|
+
this.params = params !== null && typeof params === 'object' ? params : {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read one error code without letting a hostile getter escape. */
|
|
114
|
+
function readErrorCode(error) {
|
|
115
|
+
try {
|
|
116
|
+
if (error === null || typeof error !== 'object') return undefined;
|
|
117
|
+
const code = error.code;
|
|
118
|
+
return typeof code === 'string' ? code : undefined;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Localize a known action failure, or return undefined so the caller can use
|
|
126
|
+
* its own contextual fallback. Never throws.
|
|
127
|
+
*/
|
|
128
|
+
function describeActionError(t, error) {
|
|
129
|
+
try {
|
|
130
|
+
const code = readErrorCode(error);
|
|
131
|
+
if (code === undefined || !ACTION_ERROR_CODES.includes(code)) return undefined;
|
|
132
|
+
let extras = {};
|
|
133
|
+
try {
|
|
134
|
+
if (error.params !== null && typeof error.params === 'object') extras = error.params;
|
|
135
|
+
} catch {
|
|
136
|
+
extras = {};
|
|
137
|
+
}
|
|
138
|
+
const params = { message: messageOf(error), ...extras };
|
|
139
|
+
if (typeof t === 'function') {
|
|
140
|
+
try {
|
|
141
|
+
const localized = t(`error.${code}`, params);
|
|
142
|
+
if (typeof localized === 'string' && localized !== '') return localized;
|
|
143
|
+
} catch {
|
|
144
|
+
// Fall through to the English fallback.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return messageOf(error);
|
|
148
|
+
} catch {
|
|
149
|
+
try {
|
|
150
|
+
return messageOf(error);
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Normalize the fresh-session switch reader.
|
|
159
|
+
*
|
|
160
|
+
* The public shape is `{ unlimitedBudget, includeOriginalInstructions,
|
|
161
|
+
* includeMinimalContext }`. A plain boolean remains accepted for callers
|
|
162
|
+
* that only know the legacy budget switch; every missing or unreadable
|
|
163
|
+
* value falls back to on.
|
|
164
|
+
*/
|
|
165
|
+
function normalizePromptSwitches(read) {
|
|
166
|
+
try {
|
|
167
|
+
const value = typeof read === 'function' ? read() : undefined;
|
|
168
|
+
if (typeof value === 'boolean') {
|
|
169
|
+
return { unlimitedBudget: value, includeOriginalInstructions: true, includeMinimalContext: true };
|
|
170
|
+
}
|
|
171
|
+
if (value !== null && typeof value === 'object') {
|
|
172
|
+
return {
|
|
173
|
+
unlimitedBudget: value.unlimitedBudget !== false,
|
|
174
|
+
includeOriginalInstructions: value.includeOriginalInstructions !== false,
|
|
175
|
+
includeMinimalContext: value.includeMinimalContext !== false,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Fall through to the defaults.
|
|
180
|
+
}
|
|
181
|
+
return { unlimitedBudget: true, includeOriginalInstructions: true, includeMinimalContext: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Resolve one prompt label, with an English fallback when `t` is unavailable. */
|
|
185
|
+
function translate(t, key, fallback, params) {
|
|
186
|
+
if (typeof t === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
const value = t(key, params);
|
|
189
|
+
if (typeof value === 'string' && value !== '') return value;
|
|
190
|
+
} catch {
|
|
191
|
+
// Fall through to the fallback.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (params === undefined) return fallback;
|
|
195
|
+
return Object.entries(params).reduce(
|
|
196
|
+
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
|
197
|
+
fallback,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Join the text blocks of one user message. */
|
|
202
|
+
function textOf(content) {
|
|
203
|
+
if (typeof content === 'string') return content.trim();
|
|
204
|
+
if (!Array.isArray(content)) return '';
|
|
205
|
+
return content
|
|
206
|
+
.filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
207
|
+
.map((block) => block.text)
|
|
208
|
+
.join('\n')
|
|
209
|
+
.trim();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether one session event belongs to the real user rather than an injected context. */
|
|
213
|
+
function isUserMessage(event) {
|
|
214
|
+
if (event === null || typeof event !== 'object' || event.type !== 'user/message') return false;
|
|
215
|
+
const kind = event.data?.source?.kind;
|
|
216
|
+
return kind === undefined || kind === 'user';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Locate the start of the current plan-mode episode in one event window.
|
|
221
|
+
*
|
|
222
|
+
* `plan/mode` is the durable state log; a `/plan` command is the fallback
|
|
223
|
+
* when the state event is outside the loaded window. The latest matching
|
|
224
|
+
* marker wins. The current turn is only a last-resort fallback after the
|
|
225
|
+
* caller has exhausted its older-page reads.
|
|
226
|
+
*/
|
|
227
|
+
function findPlanModeStart(entries) {
|
|
228
|
+
let planStart;
|
|
229
|
+
let commandStart;
|
|
230
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
231
|
+
const event = entries[index]?.event;
|
|
232
|
+
if (event?.type === 'plan/mode' && event.data?.active === true) {
|
|
233
|
+
planStart = { index, kind: 'plan-mode' };
|
|
234
|
+
} else if (event?.type === 'command/run' && event.data?.name === 'plan') {
|
|
235
|
+
const args = typeof event.data.args === 'string' ? event.data.args.trim() : '';
|
|
236
|
+
if (args !== 'off') commandStart = { index, kind: 'plan-command', args: event.data.args };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (planStart !== undefined && (commandStart === undefined || planStart.index >= commandStart.index)) {
|
|
240
|
+
return planStart;
|
|
241
|
+
}
|
|
242
|
+
return commandStart;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Locate the latest turn start; used only when no plan-mode marker is loaded. */
|
|
246
|
+
function findTurnStart(entries) {
|
|
247
|
+
let turnStart;
|
|
248
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
249
|
+
if (entries[index]?.event?.type === 'turn/start') turnStart = { index, kind: 'turn-start' };
|
|
250
|
+
}
|
|
251
|
+
return turnStart;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Collect every real user text after the plan-mode marker, in order.
|
|
256
|
+
*
|
|
257
|
+
* Injected user-role messages (`runtime-context`, `agent-instructions`,
|
|
258
|
+
* `time-context`, `skill-catalog`, and so on) are excluded. Without a
|
|
259
|
+
* marker only the latest real user text is returned, which avoids carrying
|
|
260
|
+
* unrelated older tasks into the fresh session.
|
|
261
|
+
*/
|
|
262
|
+
function collectPlanModeInstructions(entries, start) {
|
|
263
|
+
const from = start === undefined ? -1 : start.index;
|
|
264
|
+
const texts = [];
|
|
265
|
+
for (let index = from + 1; index < entries.length; index += 1) {
|
|
266
|
+
const event = entries[index]?.event;
|
|
267
|
+
if (!isUserMessage(event)) continue;
|
|
268
|
+
const text = textOf(event.data?.content);
|
|
269
|
+
if (text !== '') texts.push(text);
|
|
270
|
+
}
|
|
271
|
+
if (texts.length > 0) return start === undefined ? [texts[texts.length - 1]] : texts;
|
|
272
|
+
if (start?.kind === 'plan-command') {
|
|
273
|
+
const args = typeof start.args === 'string' ? start.args.trim() : '';
|
|
274
|
+
if (args !== '') return [args];
|
|
275
|
+
}
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Trim, drop empty and non-string instruction texts while keeping the order. */
|
|
280
|
+
function normalizeInstructionTexts(instructions) {
|
|
281
|
+
if (!Array.isArray(instructions)) return [];
|
|
282
|
+
return instructions
|
|
283
|
+
.map((text) => (typeof text === 'string' ? text.trim() : ''))
|
|
284
|
+
.filter((text) => text !== '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Render the original-instructions block; empty when nothing is carried. */
|
|
288
|
+
function buildInstructionBlock(texts, t) {
|
|
289
|
+
if (texts.length === 0) return '';
|
|
290
|
+
const heading = translate(
|
|
291
|
+
t,
|
|
292
|
+
'clear.instructionsIntro',
|
|
293
|
+
'User instructions sent during plan mode, in chronological order (background for the approved plan):',
|
|
294
|
+
);
|
|
295
|
+
return `${heading}\n\n<${PLAN_MODE_INSTRUCTIONS_TAG}>\n${texts.join(INSTRUCTIONS_SEPARATOR)}\n</${PLAN_MODE_INSTRUCTIONS_TAG}>\n\n`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read the current plan-mode user texts from the old session's event window.
|
|
300
|
+
*
|
|
301
|
+
* Older pages are loaded only while the plan-mode marker is missing, capped
|
|
302
|
+
* so a pathological session cannot delay execution indefinitely. Abort
|
|
303
|
+
* requests stop further page loads between reads. Every failure degrades to
|
|
304
|
+
* the texts already available (or an empty list); this never throws.
|
|
305
|
+
*
|
|
306
|
+
* @returns {Promise<{ texts: string[], found: boolean, kind: string|undefined, pagesLoaded: number }>}
|
|
307
|
+
*/
|
|
308
|
+
async function readPlanModeInstructions(ctx, sessionId, options = {}) {
|
|
309
|
+
const maxPages = Number.isInteger(options?.maxPages) && options.maxPages >= 0
|
|
310
|
+
? options.maxPages
|
|
311
|
+
: PLAN_MODE_LOAD_PAGES;
|
|
312
|
+
const signal = options?.signal;
|
|
313
|
+
let pagesLoaded = 0;
|
|
314
|
+
try {
|
|
315
|
+
const binding = ctx?.sessions?.binding?.(sessionId);
|
|
316
|
+
const source = binding?.eventSource;
|
|
317
|
+
if (source === undefined || source === null || typeof source.getSnapshot !== 'function') {
|
|
318
|
+
return { texts: [], found: false, kind: undefined, pagesLoaded: 0 };
|
|
319
|
+
}
|
|
320
|
+
let window = source.getSnapshot();
|
|
321
|
+
let start = findPlanModeStart(window?.entries ?? []);
|
|
322
|
+
for (let page = 0; start === undefined && window?.hasMore === true && page < maxPages; page += 1) {
|
|
323
|
+
if (signal?.aborted === true) break;
|
|
324
|
+
if (typeof binding.session?.loadOlder !== 'function') break;
|
|
325
|
+
try {
|
|
326
|
+
await binding.session.loadOlder();
|
|
327
|
+
} catch {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
pagesLoaded += 1;
|
|
331
|
+
try {
|
|
332
|
+
window = source.getSnapshot();
|
|
333
|
+
} catch {
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (signal?.aborted === true) break;
|
|
337
|
+
start = findPlanModeStart(window?.entries ?? []);
|
|
338
|
+
}
|
|
339
|
+
if (start === undefined) start = findTurnStart(window?.entries ?? []);
|
|
340
|
+
const texts = collectPlanModeInstructions(window?.entries ?? [], start);
|
|
341
|
+
return {
|
|
342
|
+
texts,
|
|
343
|
+
found: texts.length > 0,
|
|
344
|
+
kind: start?.kind,
|
|
345
|
+
pagesLoaded,
|
|
346
|
+
};
|
|
347
|
+
} catch {
|
|
348
|
+
return { texts: [], found: false, kind: undefined, pagesLoaded };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Read the browser's platform description without throwing. */
|
|
353
|
+
function readSystemName(nav) {
|
|
354
|
+
if (nav === undefined || nav === null) return undefined;
|
|
355
|
+
const modern = typeof nav.userAgentData?.platform === 'string' ? nav.userAgentData.platform.trim() : '';
|
|
356
|
+
const legacy = typeof nav.platform === 'string' ? nav.platform.trim() : '';
|
|
357
|
+
if (modern !== '' && legacy !== '' && modern !== legacy) return `${modern} (${legacy})`;
|
|
358
|
+
if (modern !== '') return modern;
|
|
359
|
+
if (legacy !== '') return legacy;
|
|
360
|
+
if (typeof nav.userAgent === 'string' && nav.userAgent.trim() !== '') return nav.userAgent.trim();
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Resolve the browser time zone, falling back to UTC. */
|
|
365
|
+
function resolveTimeZone() {
|
|
366
|
+
try {
|
|
367
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
368
|
+
return typeof zone === 'string' && zone !== '' ? zone : 'UTC';
|
|
369
|
+
} catch {
|
|
370
|
+
return 'UTC';
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Return a cached formatter for one zone and kind; constructing may throw for invalid zones. */
|
|
375
|
+
function formatterOf(kind, zone) {
|
|
376
|
+
const key = `${kind}|${zone}`;
|
|
377
|
+
const cached = INTL_FORMATTERS.get(key);
|
|
378
|
+
if (cached !== undefined) return cached;
|
|
379
|
+
const formatter = kind === 'offset'
|
|
380
|
+
? new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
|
|
381
|
+
: new Intl.DateTimeFormat('en-CA', {
|
|
382
|
+
timeZone: zone,
|
|
383
|
+
year: 'numeric',
|
|
384
|
+
month: '2-digit',
|
|
385
|
+
day: '2-digit',
|
|
386
|
+
hour: '2-digit',
|
|
387
|
+
minute: '2-digit',
|
|
388
|
+
second: '2-digit',
|
|
389
|
+
hourCycle: 'h23',
|
|
390
|
+
});
|
|
391
|
+
INTL_FORMATTERS.set(key, formatter);
|
|
392
|
+
return formatter;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Parse the numeric offset of a zone at one instant. */
|
|
396
|
+
function offsetMinutesOf(zone, date) {
|
|
397
|
+
try {
|
|
398
|
+
const name = formatterOf('offset', zone)
|
|
399
|
+
.formatToParts(date)
|
|
400
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
401
|
+
const match = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name ?? '');
|
|
402
|
+
if (match === null || match[1] === undefined) return 0;
|
|
403
|
+
const minutes = Number(match[2]) * 60 + Number(match[3] ?? '0');
|
|
404
|
+
return match[1] === '-' ? -minutes : minutes;
|
|
405
|
+
} catch {
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Format one instant as `YYYY-MM-DDTHH:mm:ss±HH:MM[Zone]`. */
|
|
411
|
+
function formatTimestamp(date, zone = resolveTimeZone()) {
|
|
412
|
+
try {
|
|
413
|
+
const formatter = formatterOf('datetime', zone);
|
|
414
|
+
const parts = {};
|
|
415
|
+
for (const part of formatter.formatToParts(date)) parts[part.type] = part.value;
|
|
416
|
+
const offset = offsetMinutesOf(zone, date);
|
|
417
|
+
const sign = offset >= 0 ? '+' : '-';
|
|
418
|
+
const absolute = Math.abs(offset);
|
|
419
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
420
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}[${zone}]`;
|
|
421
|
+
} catch {
|
|
422
|
+
return date.toISOString();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Read the current browser-side runtime facts used by the minimal context. */
|
|
427
|
+
function readRuntimeFacts(options = {}) {
|
|
428
|
+
try {
|
|
429
|
+
const nav = options.nav === undefined ? globalThis.navigator : options.nav;
|
|
430
|
+
const now = options.now === undefined ? new Date() : options.now;
|
|
431
|
+
const zone = options.timeZone === undefined ? resolveTimeZone() : options.timeZone;
|
|
432
|
+
return { system: readSystemName(nav), time: formatTimestamp(now, zone) };
|
|
433
|
+
} catch {
|
|
434
|
+
return { system: undefined, time: undefined };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Build the compact fresh-session environment block for Minimal mode. */
|
|
439
|
+
function buildMinimalRuntimeContext({ workspaceDirectory, system, time } = {}, t) {
|
|
440
|
+
const lines = [];
|
|
441
|
+
const directory = typeof workspaceDirectory === 'string' ? workspaceDirectory.trim() : '';
|
|
442
|
+
if (directory !== '') {
|
|
443
|
+
lines.push(translate(t, 'clear.context.workspace', 'Workspace directory: {path}', { path: directory }));
|
|
444
|
+
}
|
|
445
|
+
const systemText = typeof system === 'string' ? system.trim() : '';
|
|
446
|
+
if (systemText !== '') {
|
|
447
|
+
lines.push(translate(t, 'clear.context.system', 'System: {system}', { system: systemText }));
|
|
448
|
+
}
|
|
449
|
+
const timeText = typeof time === 'string' ? time.trim() : '';
|
|
450
|
+
if (timeText !== '') {
|
|
451
|
+
lines.push(translate(t, 'clear.context.time', 'Time: {time}', { time: timeText }));
|
|
452
|
+
}
|
|
453
|
+
lines.push(translate(
|
|
454
|
+
t,
|
|
455
|
+
'clear.context.cleared',
|
|
456
|
+
'Previous context cleared: this is a fresh session without the old conversation, system prompt, or time readings.',
|
|
457
|
+
));
|
|
458
|
+
return `<${FRESH_SESSION_CONTEXT_TAG}>\n${lines.join('\n')}\n</${FRESH_SESSION_CONTEXT_TAG}>`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Resolve every part of the fresh-session first message from the switches.
|
|
463
|
+
*
|
|
464
|
+
* This is the single funnel shared by `composeFreshPrompt`,
|
|
465
|
+
* `describeFreshPrompt`, and (through `assembleFreshPrompt`) the legacy
|
|
466
|
+
* `buildPlanExecutionPrompt`, so the preview text and the sent text cannot
|
|
467
|
+
* drift apart.
|
|
468
|
+
*/
|
|
469
|
+
function resolveFreshPromptParts({
|
|
470
|
+
plan,
|
|
471
|
+
t,
|
|
472
|
+
presetId,
|
|
473
|
+
instructions,
|
|
474
|
+
workspaceDirectory,
|
|
475
|
+
runtimeFacts,
|
|
476
|
+
switches,
|
|
477
|
+
} = {}) {
|
|
478
|
+
const planBody = typeof plan === 'string' ? plan.trim() : '';
|
|
479
|
+
if (planBody === '') throw new ActionError('emptyPlan');
|
|
480
|
+
const values = switches === null || typeof switches !== 'object' ? {} : switches;
|
|
481
|
+
const includeBudget = values.unlimitedBudget !== false;
|
|
482
|
+
const includeInstructions = values.includeOriginalInstructions !== false;
|
|
483
|
+
const includeMinimalContext = values.includeMinimalContext !== false && presetId === 'minimal';
|
|
484
|
+
const intro = translate(t, 'clear.promptIntro', 'The user approved the following plan. Execute it now without re-planning.');
|
|
485
|
+
const budget = includeBudget ? `${UNLIMITED_BUDGET_REMINDER}\n\n` : '';
|
|
486
|
+
const runtimeContext = includeMinimalContext
|
|
487
|
+
? `${buildMinimalRuntimeContext({
|
|
488
|
+
workspaceDirectory,
|
|
489
|
+
system: runtimeFacts?.system,
|
|
490
|
+
time: runtimeFacts?.time,
|
|
491
|
+
}, t)}\n\n`
|
|
492
|
+
: '';
|
|
493
|
+
const instructionTexts = normalizeInstructionTexts(instructions);
|
|
494
|
+
return {
|
|
495
|
+
t,
|
|
496
|
+
intro,
|
|
497
|
+
budget,
|
|
498
|
+
runtimeContext,
|
|
499
|
+
planBody,
|
|
500
|
+
instructionTexts,
|
|
501
|
+
carriedInstructions: includeInstructions ? instructionTexts : [],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Assemble the six first-message blocks; the only place the text is built. */
|
|
506
|
+
function assembleFreshPrompt({
|
|
507
|
+
t,
|
|
508
|
+
intro,
|
|
509
|
+
budget,
|
|
510
|
+
runtimeContext,
|
|
511
|
+
carriedInstructions,
|
|
512
|
+
planBody,
|
|
513
|
+
}) {
|
|
514
|
+
const instructionBlock = buildInstructionBlock(carriedInstructions, t);
|
|
515
|
+
const planBlock = `<approved-plan>\n${planBody}\n</approved-plan>`;
|
|
516
|
+
const handoffBlock = `\n\n${PLAN_APPROVED_HANDOFF}`;
|
|
517
|
+
return {
|
|
518
|
+
text: `${intro}\n\n${budget}${runtimeContext}${instructionBlock}${planBlock}${handoffBlock}`,
|
|
519
|
+
instructionBlock,
|
|
520
|
+
planBlock,
|
|
521
|
+
handoffBlock,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Compose the legacy execution prompt from explicit parts.
|
|
527
|
+
*
|
|
528
|
+
* Kept byte-identical to the previous implementation; it now funnels
|
|
529
|
+
* through the shared assembler.
|
|
530
|
+
*/
|
|
531
|
+
function buildPlanExecutionPrompt(plan, t, options = {}) {
|
|
532
|
+
const config = typeof options === 'boolean' ? { unlimitedBudget: options } : (options ?? {});
|
|
533
|
+
const planBody = typeof plan === 'string' ? plan.trim() : '';
|
|
534
|
+
if (planBody === '') throw new ActionError('emptyPlan');
|
|
535
|
+
const intro = translate(t, 'clear.promptIntro', 'The user approved the following plan. Execute it now without re-planning.');
|
|
536
|
+
const budget = config.unlimitedBudget === false ? '' : `${UNLIMITED_BUDGET_REMINDER}\n\n`;
|
|
537
|
+
const runtimeContext = typeof config.runtimeContext === 'string' && config.runtimeContext.trim() !== ''
|
|
538
|
+
? `${config.runtimeContext.trim()}\n\n`
|
|
539
|
+
: '';
|
|
540
|
+
return assembleFreshPrompt({
|
|
541
|
+
t,
|
|
542
|
+
intro,
|
|
543
|
+
budget,
|
|
544
|
+
runtimeContext,
|
|
545
|
+
carriedInstructions: normalizeInstructionTexts(config.instructions),
|
|
546
|
+
planBody,
|
|
547
|
+
}).text;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Compose one fresh-session prompt from the preset and switch state. */
|
|
551
|
+
function composeFreshPrompt(args = {}) {
|
|
552
|
+
return assembleFreshPrompt(resolveFreshPromptParts(args)).text;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Describe the fresh-session first message for the clear-view preview.
|
|
557
|
+
*
|
|
558
|
+
* `text` is byte-identical to `composeFreshPrompt` for the same inputs; the
|
|
559
|
+
* six blocks always appear in send order and their `chars` are the exact
|
|
560
|
+
* contributions to `text`, so they always sum to `totalChars`.
|
|
561
|
+
*/
|
|
562
|
+
function describeFreshPrompt(args = {}) {
|
|
563
|
+
const parts = resolveFreshPromptParts(args);
|
|
564
|
+
const { text, instructionBlock, planBlock, handoffBlock } = assembleFreshPrompt(parts);
|
|
565
|
+
const carried = parts.carriedInstructions;
|
|
566
|
+
const instructionChars = parts.instructionTexts.reduce((total, item) => total + item.length, 0);
|
|
567
|
+
const blocks = [
|
|
568
|
+
{
|
|
569
|
+
id: 'intro',
|
|
570
|
+
titleKey: 'preview.block.intro',
|
|
571
|
+
included: true,
|
|
572
|
+
chars: `${parts.intro}\n\n`.length,
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
id: 'budget',
|
|
576
|
+
titleKey: 'preview.block.budget',
|
|
577
|
+
included: parts.budget !== '',
|
|
578
|
+
chars: parts.budget.length,
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: 'runtime',
|
|
582
|
+
titleKey: 'preview.block.runtime',
|
|
583
|
+
included: parts.runtimeContext !== '',
|
|
584
|
+
chars: parts.runtimeContext.length,
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: 'instructions',
|
|
588
|
+
titleKey: 'preview.block.instructions',
|
|
589
|
+
included: carried.length > 0,
|
|
590
|
+
chars: instructionBlock.length,
|
|
591
|
+
count: carried.length,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 'plan',
|
|
595
|
+
titleKey: 'preview.block.plan',
|
|
596
|
+
included: true,
|
|
597
|
+
chars: planBlock.length,
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: 'approved',
|
|
601
|
+
titleKey: 'preview.block.approved',
|
|
602
|
+
included: true,
|
|
603
|
+
chars: handoffBlock.length,
|
|
604
|
+
},
|
|
605
|
+
];
|
|
606
|
+
const totalChars = text.length;
|
|
607
|
+
return {
|
|
608
|
+
text,
|
|
609
|
+
blocks,
|
|
610
|
+
instructionCount: parts.instructionTexts.length,
|
|
611
|
+
instructionChars,
|
|
612
|
+
totalChars,
|
|
613
|
+
approxTokens: Math.ceil(totalChars / 3.5),
|
|
614
|
+
oversized: totalChars > FRESH_PROMPT_WARN_CHARS,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Remove inline markdown emphasis and code markers from one preview line. */
|
|
619
|
+
function stripInlineMarkup(text) {
|
|
620
|
+
return text
|
|
621
|
+
.replace(/`+/g, '')
|
|
622
|
+
.replace(/\*\*/g, '')
|
|
623
|
+
.replace(/__/g, '')
|
|
624
|
+
.replace(/[*_]/g, '');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Collapse every whitespace run to one space and trim. */
|
|
628
|
+
function collapseWhitespace(text) {
|
|
629
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Whether one line is an ATX markdown heading. */
|
|
633
|
+
function isHeadingLine(line) {
|
|
634
|
+
return /^\s{0,3}#{1,6}\s+\S/.test(line);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Build a short plain-text summary of the reviewed plan.
|
|
639
|
+
*
|
|
640
|
+
* The summary is the first heading (without its `#`s) followed by the first
|
|
641
|
+
* paragraph, with inline markdown removed and whitespace collapsed. It is
|
|
642
|
+
* capped for the dialog; the full `plan` string is never modified or
|
|
643
|
+
* truncated anywhere else. `chars` is always the full plan length so the
|
|
644
|
+
* clear-view preview can show accurate totals.
|
|
645
|
+
*/
|
|
646
|
+
function planPreviewText(plan) {
|
|
647
|
+
if (typeof plan !== 'string') return { preview: '', chars: 0 };
|
|
648
|
+
const chars = plan.length;
|
|
649
|
+
if (plan.trim() === '') return { preview: '', chars };
|
|
650
|
+
const lines = plan.split(/\r?\n/);
|
|
651
|
+
let headingIndex = -1;
|
|
652
|
+
let firstContentIndex = -1;
|
|
653
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
654
|
+
if (lines[index].trim() === '') continue;
|
|
655
|
+
firstContentIndex = index;
|
|
656
|
+
if (isHeadingLine(lines[index])) headingIndex = index;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let headingText = '';
|
|
661
|
+
let paragraphStart = firstContentIndex;
|
|
662
|
+
if (headingIndex !== -1) {
|
|
663
|
+
headingText = collapseWhitespace(stripInlineMarkup(lines[headingIndex].replace(/^\s{0,3}#{1,6}\s*/, '')));
|
|
664
|
+
paragraphStart = headingIndex + 1;
|
|
665
|
+
}
|
|
666
|
+
while (paragraphStart < lines.length && lines[paragraphStart].trim() === '') paragraphStart += 1;
|
|
667
|
+
const paragraphLines = [];
|
|
668
|
+
for (let index = paragraphStart; index < lines.length; index += 1) {
|
|
669
|
+
const line = lines[index];
|
|
670
|
+
if (line.trim() === '' || isHeadingLine(line)) break;
|
|
671
|
+
paragraphLines.push(collapseWhitespace(stripInlineMarkup(line)));
|
|
672
|
+
}
|
|
673
|
+
const paragraphText = collapseWhitespace(paragraphLines.join(' '));
|
|
674
|
+
|
|
675
|
+
const previewParts = [];
|
|
676
|
+
if (headingText !== '') previewParts.push(headingText);
|
|
677
|
+
if (paragraphText !== '') previewParts.push(paragraphText);
|
|
678
|
+
let preview = previewParts.join('\n');
|
|
679
|
+
if (preview.length > PLAN_PREVIEW_MAX_CHARS) {
|
|
680
|
+
preview = `${preview.slice(0, PLAN_PREVIEW_MAX_CHARS).trimEnd()}…`;
|
|
681
|
+
}
|
|
682
|
+
return { preview, chars };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function approveReview({ pending, review }) {
|
|
686
|
+
if (pending === null || pending === undefined || typeof pending.answer !== 'function') {
|
|
687
|
+
throw new Error('The plan review is no longer available.');
|
|
688
|
+
}
|
|
689
|
+
const snapshot = typeof pending.snapshot === 'function' ? pending.snapshot() : undefined;
|
|
690
|
+
const channel = snapshot?.channel;
|
|
691
|
+
await pending.answer({ answers: [{ id: review.id, selected: [review.approve.label] }] });
|
|
692
|
+
if (channel === 'rpc' && typeof pending.dismiss === 'function') {
|
|
693
|
+
try {
|
|
694
|
+
await pending.dismiss();
|
|
695
|
+
} catch {
|
|
696
|
+
// The Host projection closed the card while the answer settled.
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Dismiss the pending plan review, exactly like the native Discuss action.
|
|
703
|
+
*
|
|
704
|
+
* The review stays pending and reopenable; only the card is hidden.
|
|
705
|
+
*/
|
|
706
|
+
async function dismissReview({ pending } = {}) {
|
|
707
|
+
if (pending === null || pending === undefined || typeof pending.dismiss !== 'function') {
|
|
708
|
+
throw new ActionError('dismissFailed', { message: 'The plan review is no longer available.' });
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
await pending.dismiss();
|
|
712
|
+
} catch (error) {
|
|
713
|
+
throw new ActionError('dismissFailed', { message: messageOf(error) });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Run the Host compaction command and normalize its outcome.
|
|
719
|
+
*
|
|
720
|
+
* Never throws: expected failures return `{ ok: false, message, code?, raw? }`
|
|
721
|
+
* so the panel can show a localized warning and keep its buttons usable.
|
|
722
|
+
*/
|
|
723
|
+
async function runCompactCommand({ ctx, sessionId, signal } = {}) {
|
|
724
|
+
try {
|
|
725
|
+
const result = await ctx.remote.commands.execute(sessionId, COMPACT_COMMAND, [], signal);
|
|
726
|
+
if (!result?.ok) {
|
|
727
|
+
const failure = result?.error;
|
|
728
|
+
const message = failure !== undefined && failure !== null
|
|
729
|
+
? `${failure.message} (${failure.code})`
|
|
730
|
+
: 'The /great-plan-compact command failed.';
|
|
731
|
+
const code = typeof failure?.code === 'string' ? failure.code : undefined;
|
|
732
|
+
return code === undefined ? { ok: false, message } : { ok: false, message, code };
|
|
733
|
+
}
|
|
734
|
+
if (result.value === undefined) return { ok: false, message: COMPACT_UNAVAILABLE_MESSAGE };
|
|
735
|
+
const outcome = result.value?.result;
|
|
736
|
+
if (outcome?.kind === 'success') {
|
|
737
|
+
return { ok: true, message: typeof outcome.text === 'string' ? outcome.text : '' };
|
|
738
|
+
}
|
|
739
|
+
const raw = typeof outcome?.text === 'string' ? outcome.text : '';
|
|
740
|
+
return { ok: false, message: raw, raw };
|
|
741
|
+
} catch (error) {
|
|
742
|
+
return { ok: false, message: messageOf(error) };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Localize one compact failure message by exact match. Unknown (dynamic)
|
|
748
|
+
* Host messages fall back to the original English text; never throws.
|
|
749
|
+
*/
|
|
750
|
+
function describeCompactFailure(t, message) {
|
|
751
|
+
try {
|
|
752
|
+
if (typeof message !== 'string') return message;
|
|
753
|
+
const key = COMPACT_MESSAGE_KEYS[message];
|
|
754
|
+
if (typeof key !== 'string') return message;
|
|
755
|
+
if (typeof t === 'function') {
|
|
756
|
+
try {
|
|
757
|
+
const localized = t(key);
|
|
758
|
+
if (typeof localized === 'string' && localized !== '') return localized;
|
|
759
|
+
} catch {
|
|
760
|
+
// Fall through to the Host text.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return message;
|
|
764
|
+
} catch {
|
|
765
|
+
return message;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function composeFreshTitle(title, suffix) {
|
|
770
|
+
const base = typeof title === 'string' ? title.trim().replace(/\s+/g, ' ') : '';
|
|
771
|
+
if (base === '') return undefined;
|
|
772
|
+
const tail = typeof suffix === 'string' ? suffix : '';
|
|
773
|
+
if (tail !== '' && base.endsWith(tail)) return truncate(base, 80);
|
|
774
|
+
return truncate(`${base}${tail}`, 80);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function truncate(value, max) {
|
|
778
|
+
if (value.length <= max) return value;
|
|
779
|
+
return `${value.slice(0, max - 1).trimEnd()}…`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function selectionPayload(selection) {
|
|
783
|
+
if (selection === null || selection === undefined) return undefined;
|
|
784
|
+
return {
|
|
785
|
+
provider: selection.provider,
|
|
786
|
+
model: selection.model,
|
|
787
|
+
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Abort the fresh start when the review settled between opening and prompting. */
|
|
792
|
+
async function assertReviewStillPending({ stillPending, readPending }) {
|
|
793
|
+
if (typeof stillPending === 'function') {
|
|
794
|
+
if (!await stillPending()) throw new ActionError('reviewGone');
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (typeof readPending === 'function') {
|
|
798
|
+
const pending = await readPending();
|
|
799
|
+
if (pending === null || pending === undefined) throw new ActionError('reviewGone');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Archive a session created by a failed execution; best-effort only.
|
|
805
|
+
*
|
|
806
|
+
* A cleanup failure never replaces the original error: it is logged and
|
|
807
|
+
* attached as `.cleanupError` so the dialog can mention it.
|
|
808
|
+
*/
|
|
809
|
+
async function archiveFailedExecution(ctx, sessionId, error) {
|
|
810
|
+
try {
|
|
811
|
+
await ctx.uiWorkspace.archiveSession(sessionId, { stopActivity: true });
|
|
812
|
+
} catch (cleanupError) {
|
|
813
|
+
const message = messageOf(cleanupError);
|
|
814
|
+
console.warn('great-plan: the unused new session could not be archived:', message);
|
|
815
|
+
try {
|
|
816
|
+
error.cleanupError = message;
|
|
817
|
+
} catch {
|
|
818
|
+
// A thrown primitive cannot carry the cleanup detail.
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Create a fresh session in the same workspace, send the composed first
|
|
825
|
+
* message, then stop the old session and open the new one.
|
|
826
|
+
*
|
|
827
|
+
* Ordering is load-bearing: plan-mode instructions are read in parallel
|
|
828
|
+
* with session creation; the still-pending check runs right before the
|
|
829
|
+
* prompt; every failure after creation archives the new session; the old
|
|
830
|
+
* session is cancelled before the new one is opened (the open is
|
|
831
|
+
* synchronous and may throw).
|
|
832
|
+
*/
|
|
833
|
+
async function createFreshExecution(args = {}) {
|
|
834
|
+
const {
|
|
835
|
+
ctx,
|
|
836
|
+
sessionId,
|
|
837
|
+
review,
|
|
838
|
+
presetId,
|
|
839
|
+
workspaceId,
|
|
840
|
+
workspaceDirectory,
|
|
841
|
+
cwd,
|
|
842
|
+
selection,
|
|
843
|
+
title,
|
|
844
|
+
titleSuffix,
|
|
845
|
+
signal,
|
|
846
|
+
t,
|
|
847
|
+
readPromptSwitches,
|
|
848
|
+
runtimeFacts,
|
|
849
|
+
instructions,
|
|
850
|
+
switches,
|
|
851
|
+
stillPending,
|
|
852
|
+
readPending,
|
|
853
|
+
} = args;
|
|
854
|
+
|
|
855
|
+
const planBody = typeof review?.plan === 'string' ? review.plan.trim() : '';
|
|
856
|
+
if (planBody === '') throw new ActionError('emptyPlan');
|
|
857
|
+
|
|
858
|
+
const resolvedSwitches = switches === undefined
|
|
859
|
+
? normalizePromptSwitches(readPromptSwitches)
|
|
860
|
+
: normalizePromptSwitches(() => switches);
|
|
861
|
+
const reusedInstructions = Array.isArray(instructions) ? normalizeInstructionTexts(instructions) : undefined;
|
|
862
|
+
const instructionPromise = resolvedSwitches.includeOriginalInstructions && reusedInstructions === undefined
|
|
863
|
+
? readPlanModeInstructions(ctx, sessionId, { signal }).catch(() => ({
|
|
864
|
+
texts: [],
|
|
865
|
+
found: false,
|
|
866
|
+
kind: undefined,
|
|
867
|
+
pagesLoaded: 0,
|
|
868
|
+
}))
|
|
869
|
+
: undefined;
|
|
870
|
+
|
|
871
|
+
const location = workspaceId !== undefined ? { workspaceId } : cwd !== undefined ? { cwd } : {};
|
|
872
|
+
let newSessionId;
|
|
873
|
+
try {
|
|
874
|
+
newSessionId = await ctx.sessions.create({
|
|
875
|
+
...location,
|
|
876
|
+
...presetId === undefined ? {} : { agentPreset: presetId },
|
|
877
|
+
});
|
|
878
|
+
} catch (error) {
|
|
879
|
+
throw new ActionError('createSessionFailed', { message: messageOf(error) });
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const instructionTexts = resolvedSwitches.includeOriginalInstructions
|
|
884
|
+
? (reusedInstructions ?? (instructionPromise === undefined ? [] : (await instructionPromise).texts))
|
|
885
|
+
: [];
|
|
886
|
+
const facts = resolvedSwitches.includeMinimalContext && presetId === 'minimal'
|
|
887
|
+
? (runtimeFacts ?? readRuntimeFacts())
|
|
888
|
+
: undefined;
|
|
889
|
+
const promptText = composeFreshPrompt({
|
|
890
|
+
plan: review?.plan,
|
|
891
|
+
t,
|
|
892
|
+
presetId,
|
|
893
|
+
instructions: instructionTexts,
|
|
894
|
+
workspaceDirectory: workspaceDirectory ?? cwd,
|
|
895
|
+
runtimeFacts: facts,
|
|
896
|
+
switches: resolvedSwitches,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
await ctx.sessions.using(newSessionId, { source: 'greatPlan', signal }, async (reference) => {
|
|
900
|
+
const session = reference.binding.session;
|
|
901
|
+
const renameTask = (async () => {
|
|
902
|
+
const freshTitle = composeFreshTitle(title, titleSuffix);
|
|
903
|
+
if (freshTitle === undefined) return;
|
|
904
|
+
try {
|
|
905
|
+
await session.rename(freshTitle);
|
|
906
|
+
} catch {
|
|
907
|
+
// The title prefix is best-effort; execution must not depend on it.
|
|
908
|
+
}
|
|
909
|
+
})();
|
|
910
|
+
const selectionTask = (async () => {
|
|
911
|
+
const payload = selectionPayload(selection);
|
|
912
|
+
if (payload === undefined) return;
|
|
913
|
+
let selected;
|
|
914
|
+
try {
|
|
915
|
+
selected = await ctx.remote.session.selectModel({ sessionId: newSessionId, ...payload });
|
|
916
|
+
} catch (error) {
|
|
917
|
+
throw new ActionError('modelApplyFailed', { message: messageOf(error) });
|
|
918
|
+
}
|
|
919
|
+
if (!selected?.ok) {
|
|
920
|
+
throw new ActionError('modelApplyFailed', { message: rpcFailureMessage(selected) });
|
|
921
|
+
}
|
|
922
|
+
})();
|
|
923
|
+
await Promise.all([renameTask, selectionTask]);
|
|
924
|
+
await assertReviewStillPending({ stillPending, readPending });
|
|
925
|
+
let prompt;
|
|
926
|
+
try {
|
|
927
|
+
prompt = await session.prompt(
|
|
928
|
+
[{ type: 'text', text: promptText }],
|
|
929
|
+
'queue',
|
|
930
|
+
signal,
|
|
931
|
+
);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
throw new ActionError('startSessionFailed', { message: messageOf(error) });
|
|
934
|
+
}
|
|
935
|
+
if (!prompt?.ok) {
|
|
936
|
+
throw new ActionError('startSessionFailed', { message: rpcFailureMessage(prompt) });
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
} catch (error) {
|
|
940
|
+
await archiveFailedExecution(ctx, newSessionId, error);
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let cancelError;
|
|
945
|
+
try {
|
|
946
|
+
const previous = ctx.sessions.binding?.(sessionId);
|
|
947
|
+
if (previous !== undefined && previous !== null) {
|
|
948
|
+
let cancelled;
|
|
949
|
+
try {
|
|
950
|
+
cancelled = await previous.session.cancel();
|
|
951
|
+
} catch (error) {
|
|
952
|
+
cancelError = messageOf(error);
|
|
953
|
+
}
|
|
954
|
+
if (cancelled !== undefined && cancelled !== null && cancelled.ok === false) {
|
|
955
|
+
const detail = cancelled.error;
|
|
956
|
+
cancelError = detail === undefined || detail === null
|
|
957
|
+
? 'The previous session could not be stopped.'
|
|
958
|
+
: `${detail.message} (${detail.code})`;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
} catch (error) {
|
|
962
|
+
cancelError = messageOf(error);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
ctx.uiWorkspace.openSession(newSessionId);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
console.warn('great-plan: opening the new session failed:', messageOf(error));
|
|
969
|
+
const wrapped = new ActionError('openSessionFailed', { message: messageOf(error) });
|
|
970
|
+
await archiveFailedExecution(ctx, newSessionId, wrapped);
|
|
971
|
+
throw wrapped;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return { sessionId: newSessionId, cancelError };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Render one RPC failure detail as `message (code)`. */
|
|
978
|
+
function rpcFailureMessage(result) {
|
|
979
|
+
const detail = result?.error;
|
|
980
|
+
if (detail === undefined || detail === null) return 'unknown error';
|
|
981
|
+
return `${detail.message} (${detail.code})`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function createActions(ctx, sessionId, readPromptSwitches) {
|
|
985
|
+
return {
|
|
986
|
+
approveReview,
|
|
987
|
+
composeFreshTitle,
|
|
988
|
+
runCompactCommand: (args) => runCompactCommand({ ctx, sessionId, ...args }),
|
|
989
|
+
createFreshExecution: (args) => createFreshExecution({ ctx, sessionId, ...args, readPromptSwitches }),
|
|
990
|
+
readPlanModeInstructions: (options) => readPlanModeInstructions(ctx, sessionId, options),
|
|
991
|
+
dismissReview: (args) => dismissReview(args),
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return {
|
|
996
|
+
messageOf,
|
|
997
|
+
UNLIMITED_BUDGET_REMINDER,
|
|
998
|
+
PLAN_APPROVED_HANDOFF,
|
|
999
|
+
PLAN_MODE_INSTRUCTIONS_TAG,
|
|
1000
|
+
FRESH_SESSION_CONTEXT_TAG,
|
|
1001
|
+
FRESH_PROMPT_WARN_CHARS,
|
|
1002
|
+
ACTION_ERROR_CODES,
|
|
1003
|
+
ACTION_ERROR_MESSAGES,
|
|
1004
|
+
COMPACT_MESSAGE_KEYS,
|
|
1005
|
+
ActionError,
|
|
1006
|
+
describeActionError,
|
|
1007
|
+
describeCompactFailure,
|
|
1008
|
+
describeFreshPrompt,
|
|
1009
|
+
planPreviewText,
|
|
1010
|
+
normalizePromptSwitches,
|
|
1011
|
+
textOf,
|
|
1012
|
+
findPlanModeStart,
|
|
1013
|
+
findTurnStart,
|
|
1014
|
+
collectPlanModeInstructions,
|
|
1015
|
+
readPlanModeInstructions,
|
|
1016
|
+
readSystemName,
|
|
1017
|
+
resolveTimeZone,
|
|
1018
|
+
formatTimestamp,
|
|
1019
|
+
readRuntimeFacts,
|
|
1020
|
+
buildMinimalRuntimeContext,
|
|
1021
|
+
composeFreshPrompt,
|
|
1022
|
+
approveReview,
|
|
1023
|
+
dismissReview,
|
|
1024
|
+
runCompactCommand,
|
|
1025
|
+
createFreshExecution,
|
|
1026
|
+
createActions,
|
|
1027
|
+
composeFreshTitle,
|
|
1028
|
+
buildPlanExecutionPrompt,
|
|
1029
|
+
};
|
|
1030
|
+
},
|
|
1031
|
+
});
|