@toddzheng024/dscode-bundle 0.7.12 → 0.7.14
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/THIRD_PARTY_NOTICES.md +3 -3
- package/cordis.patch.yml +6 -2
- package/package.json +7 -5
- package/plugins/code-review/index.mjs +5 -2
- package/plugins/compaction/threshold.mjs +12 -0
- package/plugins/credentials/index.mjs +13 -0
- package/plugins/grok/adapter.mjs +114 -0
- package/plugins/grok/auth.mjs +76 -0
- package/plugins/grok/billing.mjs +146 -0
- package/plugins/grok/index.mjs +118 -0
- package/plugins/grok/models.mjs +129 -0
- package/plugins/grok/status.mjs +28 -0
- package/plugins/grok/wire.mjs +268 -0
- package/plugins/i18n/messages.mjs +6 -6
- package/plugins/providers/catalog.mjs +2 -0
- package/plugins/session-metrics/view.mjs +34 -3
- package/vendor/compaction-basic/index.js +91 -1
- package/vendor/persistent/index.js +10 -9
- package/vendor/terminal/index.js +5 -0
- package/vendor/tui/lib/app.mjs +6711 -0
- package/vendor/tui/lib/approval.mjs +122 -0
- package/vendor/tui/lib/attachments.mjs +218 -0
- package/vendor/tui/lib/authorization-panel.mjs +242 -0
- package/vendor/tui/lib/authorization.mjs +111 -0
- package/vendor/tui/lib/commands.mjs +123 -0
- package/vendor/tui/lib/dscode/chat.mjs +84 -0
- package/vendor/tui/lib/dscode/flags.mjs +32 -0
- package/vendor/tui/lib/dscode/model-search.mjs +68 -0
- package/vendor/tui/lib/dscode/paste.mjs +87 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
- package/vendor/tui/lib/dscode/welcome.mjs +100 -0
- package/vendor/tui/lib/editor-keys.mjs +346 -0
- package/vendor/tui/lib/editor.mjs +50 -0
- package/vendor/tui/lib/fork.mjs +24 -0
- package/vendor/tui/lib/git-workflow.mjs +278 -0
- package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
- package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
- package/vendor/tui/lib/index.mjs +2200 -0
- package/vendor/tui/lib/input-split.mjs +170 -0
- package/vendor/tui/lib/internals.mjs +90 -0
- package/vendor/tui/lib/invariant.mjs +22 -0
- package/vendor/tui/lib/kernel-panels.mjs +1265 -0
- package/vendor/tui/lib/keyboard.mjs +283 -0
- package/vendor/tui/lib/language-panel.mjs +41 -0
- package/vendor/tui/lib/locales/en.mjs +495 -0
- package/vendor/tui/lib/locales/zh.mjs +495 -0
- package/vendor/tui/lib/mentions.mjs +150 -0
- package/vendor/tui/lib/model-capabilities.mjs +245 -0
- package/vendor/tui/lib/models.mjs +215 -0
- package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
- package/vendor/tui/lib/permissions.mjs +45 -0
- package/vendor/tui/lib/plugin-inventory.mjs +27 -0
- package/vendor/tui/lib/presets.mjs +46 -0
- package/vendor/tui/lib/provider-settings.mjs +600 -0
- package/vendor/tui/lib/questions.mjs +116 -0
- package/vendor/tui/lib/rainbow.mjs +181 -0
- package/vendor/tui/lib/render/animations.mjs +665 -0
- package/vendor/tui/lib/render/editor.mjs +437 -0
- package/vendor/tui/lib/render/export.mjs +119 -0
- package/vendor/tui/lib/render/fuzzy.mjs +79 -0
- package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
- package/vendor/tui/lib/render/inspector.mjs +93 -0
- package/vendor/tui/lib/render/lines.mjs +538 -0
- package/vendor/tui/lib/render/markdown.mjs +592 -0
- package/vendor/tui/lib/render/projection.mjs +1864 -0
- package/vendor/tui/lib/render/status.mjs +591 -0
- package/vendor/tui/lib/render/text.mjs +157 -0
- package/vendor/tui/lib/render/tool-detail.mjs +187 -0
- package/vendor/tui/lib/render/tool-preview.mjs +79 -0
- package/vendor/tui/lib/render/usage.mjs +336 -0
- package/vendor/tui/lib/render/width.mjs +191 -0
- package/vendor/tui/lib/session-directory.mjs +329 -0
- package/vendor/tui/lib/session-query.mjs +184 -0
- package/vendor/tui/lib/session-switch.mjs +51 -0
- package/vendor/tui/lib/settings-file.mjs +75 -0
- package/vendor/tui/lib/skills.mjs +119 -0
- package/vendor/tui/lib/startup.mjs +111 -0
- package/vendor/tui/lib/store.mjs +129 -0
- package/vendor/tui/lib/subagents.mjs +200 -0
- package/vendor/tui/lib/terminal-title.mjs +186 -0
- package/vendor/tui/lib/theme-panel.mjs +60 -0
- package/vendor/tui/lib/theme.mjs +379 -0
- package/vendor/tui/lib/update-panel.mjs +239 -0
- package/vendor/tui/lib/update.mjs +101 -0
- package/vendor/tui/lib/version.mjs +105 -0
- package/vendor/tui/lib/whale-glyph.mjs +20 -0
- package/vendor/tui/LICENSE +0 -21
- package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
- package/vendor/tui/dscode-email/cli.mjs +0 -32
- package/vendor/tui/dscode-email/contacts.mjs +0 -36
- package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
- package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
- package/vendor/tui/dscode-email/gmail.mjs +0 -194
- package/vendor/tui/dscode-email/imap.mjs +0 -136
- package/vendor/tui/dscode-email/inbox.d.mts +0 -25
- package/vendor/tui/dscode-email/inbox.mjs +0 -76
- package/vendor/tui/dscode-email/smtp.mjs +0 -69
- package/vendor/tui/dscode-email/store.mjs +0 -32
- package/vendor/tui/dscode-providers/catalog.mjs +0 -136
- package/vendor/tui/dscode-providers/effort.mjs +0 -35
- package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
- package/vendor/tui/index.mjs +0 -44441
- package/vendor/tui/invariant.mjs +0 -21
- package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
- package/vendor/tui/session-query.mjs +0 -150
- package/vendor/tui/startup.mjs +0 -109
- package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
- package/vendor/tui/types/app.d.ts +0 -348
- package/vendor/tui/types/approval.d.ts +0 -59
- package/vendor/tui/types/attachments.d.ts +0 -52
- package/vendor/tui/types/authorization-panel.d.ts +0 -22
- package/vendor/tui/types/authorization.d.ts +0 -36
- package/vendor/tui/types/commands.d.ts +0 -52
- package/vendor/tui/types/editor-keys.d.ts +0 -105
- package/vendor/tui/types/editor.d.ts +0 -6
- package/vendor/tui/types/fork.d.ts +0 -8
- package/vendor/tui/types/git-workflow.d.ts +0 -121
- package/vendor/tui/types/index.d.ts +0 -223
- package/vendor/tui/types/input-split.d.ts +0 -54
- package/vendor/tui/types/internals.d.ts +0 -26
- package/vendor/tui/types/invariant.d.ts +0 -15
- package/vendor/tui/types/kernel-panels.d.ts +0 -245
- package/vendor/tui/types/keyboard.d.ts +0 -80
- package/vendor/tui/types/language-panel.d.ts +0 -12
- package/vendor/tui/types/locales/en.d.ts +0 -450
- package/vendor/tui/types/locales/zh.d.ts +0 -9
- package/vendor/tui/types/mentions.d.ts +0 -85
- package/vendor/tui/types/model-capabilities.d.ts +0 -82
- package/vendor/tui/types/models.d.ts +0 -133
- package/vendor/tui/types/permissions.d.ts +0 -27
- package/vendor/tui/types/plugin-inventory.d.ts +0 -11
- package/vendor/tui/types/presets.d.ts +0 -22
- package/vendor/tui/types/provider-settings.d.ts +0 -239
- package/vendor/tui/types/questions.d.ts +0 -54
- package/vendor/tui/types/rainbow.d.ts +0 -69
- package/vendor/tui/types/render/animations.d.ts +0 -307
- package/vendor/tui/types/render/editor.d.ts +0 -163
- package/vendor/tui/types/render/export.d.ts +0 -9
- package/vendor/tui/types/render/fuzzy.d.ts +0 -21
- package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
- package/vendor/tui/types/render/inspector.d.ts +0 -62
- package/vendor/tui/types/render/lines.d.ts +0 -86
- package/vendor/tui/types/render/markdown.d.ts +0 -29
- package/vendor/tui/types/render/projection.d.ts +0 -587
- package/vendor/tui/types/render/status.d.ts +0 -196
- package/vendor/tui/types/render/text.d.ts +0 -64
- package/vendor/tui/types/render/tool-detail.d.ts +0 -94
- package/vendor/tui/types/render/tool-preview.d.ts +0 -28
- package/vendor/tui/types/render/usage.d.ts +0 -113
- package/vendor/tui/types/render/width.d.ts +0 -29
- package/vendor/tui/types/session-directory.d.ts +0 -190
- package/vendor/tui/types/session-query.d.ts +0 -92
- package/vendor/tui/types/session-switch.d.ts +0 -17
- package/vendor/tui/types/settings-file.d.ts +0 -41
- package/vendor/tui/types/skills.d.ts +0 -47
- package/vendor/tui/types/startup.d.ts +0 -65
- package/vendor/tui/types/store.d.ts +0 -58
- package/vendor/tui/types/subagents.d.ts +0 -70
- package/vendor/tui/types/terminal-title.d.ts +0 -66
- package/vendor/tui/types/theme-panel.d.ts +0 -24
- package/vendor/tui/types/theme.d.ts +0 -434
- package/vendor/tui/types/update-panel.d.ts +0 -49
- package/vendor/tui/types/update.d.ts +0 -75
- package/vendor/tui/types/version.d.ts +0 -19
- package/vendor/tui/types/whale-glyph.d.ts +0 -6
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/** Lightweight session-directory projection for the /resume picker. */
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session';
|
|
5
|
+
import { t } from './i18n.mjs';
|
|
6
|
+
/** Case-insensitive filesystems (Windows, macOS) compare paths by lowercased form. */
|
|
7
|
+
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
|
|
8
|
+
/** True only for delegated subagents; ordinary forks also carry lineage. */
|
|
9
|
+
export function isSubagentSession(header) {
|
|
10
|
+
return header.origin === 'subagent';
|
|
11
|
+
}
|
|
12
|
+
function comparablePath(value) {
|
|
13
|
+
const resolved = resolve(value);
|
|
14
|
+
// Codex's paths_match_after_normalization pattern: canonicalize through the
|
|
15
|
+
// filesystem when the path exists (resolving symlinks, subst drives, and
|
|
16
|
+
// junctions that plain `resolve` keeps distinct), falling back to lexical
|
|
17
|
+
// resolution when the directory no longer does.
|
|
18
|
+
const fold = (path) => CASE_INSENSITIVE_FS ? path.toLowerCase() : path;
|
|
19
|
+
try {
|
|
20
|
+
return fold(realpathSync(resolved));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return fold(resolved);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Platform-consistent path equality for session cwd comparisons. */
|
|
27
|
+
function samePath(left, right) {
|
|
28
|
+
if (left === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
return comparablePath(left) === comparablePath(right);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Unique header match by exact id or unique id prefix (root and subagent
|
|
34
|
+
* headers alike); the caller applies any lineage gate.
|
|
35
|
+
* @param headers - the persisted headers.
|
|
36
|
+
* @param wanted - the id or id prefix.
|
|
37
|
+
* @returns the uniquely matched header.
|
|
38
|
+
* @throws when nothing matches or the prefix is ambiguous.
|
|
39
|
+
*/
|
|
40
|
+
export function matchSessionId(headers, wanted) {
|
|
41
|
+
const exact = headers.filter(header => header.id === wanted);
|
|
42
|
+
const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted));
|
|
43
|
+
if (matches.length === 0)
|
|
44
|
+
throw new Error(`no persisted session matches "${wanted}"`);
|
|
45
|
+
if (matches.length > 1) {
|
|
46
|
+
throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
|
|
47
|
+
}
|
|
48
|
+
return matches[0];
|
|
49
|
+
}
|
|
50
|
+
/** The newest persisted ROOT session pinned to this cwd, or undefined. */
|
|
51
|
+
export function newestRootForCwd(headers, cwd) {
|
|
52
|
+
const local = headers
|
|
53
|
+
.filter(header => !isSubagentSession(header) && samePath(header.cwd, cwd))
|
|
54
|
+
.sort((left, right) => right.createdAt - left.createdAt);
|
|
55
|
+
return local[0];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Filter/sort header-only records. No session log is loaded here. Sorting is
|
|
59
|
+
* by LAST ACTIVITY (`updated` — artifact mtime when the caller resolved one,
|
|
60
|
+
* else createdAt), matching the codex resume picker's default UpdatedAt
|
|
61
|
+
* ordering: a session you kept talking in outranks one created later but idle.
|
|
62
|
+
* @param records - the header-only records.
|
|
63
|
+
* @param options - filter/sort options.
|
|
64
|
+
* @param updated - per-session last-activity timestamps, when resolved.
|
|
65
|
+
*/
|
|
66
|
+
export function projectSessionRows(records, options, updated) {
|
|
67
|
+
const needle = options.query.trim().toLowerCase();
|
|
68
|
+
return records
|
|
69
|
+
.filter(record => options.sessions === 'all' || !isSubagentSession(record.header))
|
|
70
|
+
.filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
|
|
71
|
+
.map(record => {
|
|
72
|
+
const cwd = record.header.cwd ?? '';
|
|
73
|
+
const subagent = isSubagentSession(record.header);
|
|
74
|
+
const activity = updated?.get(record.header.id);
|
|
75
|
+
return {
|
|
76
|
+
id: record.header.id,
|
|
77
|
+
createdAt: record.header.createdAt,
|
|
78
|
+
updatedAt: activity === undefined || !Number.isFinite(activity) || activity < record.header.createdAt
|
|
79
|
+
? record.header.createdAt
|
|
80
|
+
: activity,
|
|
81
|
+
cwd,
|
|
82
|
+
workspace: cwd === '' ? '(no workspace)' : basename(cwd),
|
|
83
|
+
parent: record.header.parentSession,
|
|
84
|
+
subagent,
|
|
85
|
+
resumable: !subagent,
|
|
86
|
+
live: record.live,
|
|
87
|
+
persisted: record.persisted,
|
|
88
|
+
preset: record.header.agentPreset ?? 'standard',
|
|
89
|
+
};
|
|
90
|
+
})
|
|
91
|
+
.filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
|
|
92
|
+
.sort((left, right) => options.sort === 'newest'
|
|
93
|
+
? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
|
|
94
|
+
: left.updatedAt - right.updatedAt || left.createdAt - right.createdAt);
|
|
95
|
+
}
|
|
96
|
+
/** Merge page-local title observations without disturbing directory order. */
|
|
97
|
+
export function mergeSessionTitles(rows, observations) {
|
|
98
|
+
const titles = new Map();
|
|
99
|
+
for (const observation of observations) {
|
|
100
|
+
if (observation.status !== 'fulfilled')
|
|
101
|
+
continue;
|
|
102
|
+
const title = observation.value?.title?.title;
|
|
103
|
+
if (title !== undefined && title.trim() !== '')
|
|
104
|
+
titles.set(observation.sessionId, title);
|
|
105
|
+
}
|
|
106
|
+
return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Encode a session id the way the JSONL backend does for its on-disk layout
|
|
110
|
+
* (`encodeSegment`: safe units literal, everything else `~XXXX`). Used to
|
|
111
|
+
* validate and derive session directories — a local copy of the pure upstream
|
|
112
|
+
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
113
|
+
*/
|
|
114
|
+
export function encodeSessionSegment(raw) {
|
|
115
|
+
if (raw.length === 0)
|
|
116
|
+
throw new Error('cannot encode an empty path segment');
|
|
117
|
+
if (raw === '.')
|
|
118
|
+
return '~002E';
|
|
119
|
+
if (raw === '..')
|
|
120
|
+
return '~002E~002E';
|
|
121
|
+
let out = '';
|
|
122
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
123
|
+
const code = raw.charCodeAt(i);
|
|
124
|
+
const ch = String.fromCharCode(code);
|
|
125
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/u.test(ch)) {
|
|
126
|
+
out += ch;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Encode a project cwd the way the JSONL backend groups sessions on disk
|
|
136
|
+
* (`projectKey`: separators collapse to one `-`, everything else mirrors
|
|
137
|
+
* `encodeSegment`, bounded to 251 chars). A local copy of the pure upstream
|
|
138
|
+
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
139
|
+
*/
|
|
140
|
+
export function encodeProjectKey(cwd) {
|
|
141
|
+
if (cwd.length === 0)
|
|
142
|
+
throw new Error('cannot encode an empty project path');
|
|
143
|
+
let readable = '';
|
|
144
|
+
let separatorRun = false;
|
|
145
|
+
for (let i = 0; i < cwd.length; i += 1) {
|
|
146
|
+
const code = cwd.charCodeAt(i);
|
|
147
|
+
const ch = String.fromCharCode(code);
|
|
148
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
149
|
+
if (!separatorRun)
|
|
150
|
+
readable += '-';
|
|
151
|
+
separatorRun = true;
|
|
152
|
+
}
|
|
153
|
+
else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
154
|
+
readable += ch;
|
|
155
|
+
separatorRun = false;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
159
|
+
separatorRun = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const slug = readable.replace(/^-+/, '') || 'root';
|
|
163
|
+
return `--${slug.slice(0, 251)}--`;
|
|
164
|
+
}
|
|
165
|
+
/** The project-level directory name the JSONL backend uses for a missing cwd. */
|
|
166
|
+
const NO_CWD_DIRECTORY = '_no-cwd';
|
|
167
|
+
/**
|
|
168
|
+
* Derive one session's artifact directory under the JSONL backend root,
|
|
169
|
+
* mirroring the upstream `<root>/<projectKey(cwd)>/<encodeSegment(id)>/`
|
|
170
|
+
* layout (0.1.5 `sessionDir`/`projectDir`).
|
|
171
|
+
* @param root - the JSONL backend's configured session root.
|
|
172
|
+
* @param cwd - the session's pinned working directory, when the header has one.
|
|
173
|
+
* @param id - the session id.
|
|
174
|
+
* @returns the absolute session directory path.
|
|
175
|
+
*/
|
|
176
|
+
export function sessionDirectoryFor(root, cwd, id) {
|
|
177
|
+
const project = cwd === undefined || cwd === '' ? NO_CWD_DIRECTORY : encodeProjectKey(cwd);
|
|
178
|
+
return resolve(root, project, encodeSessionSegment(id));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The canonical session-log artifact filenames the JSONL backend may create:
|
|
182
|
+
* format v0 writes the bare `session.jsonl` name; v1+ write
|
|
183
|
+
* `session.vN.jsonl`, each generation optionally zstd-compressed. Multiple
|
|
184
|
+
* immutable generations may coexist in one session directory (0.1.5). The
|
|
185
|
+
* range follows the installed session package's `SESSION_FORMAT_VERSION`, so
|
|
186
|
+
* a future generation joins the enumeration with the dependency bump.
|
|
187
|
+
*/
|
|
188
|
+
export function sessionArtifactNames() {
|
|
189
|
+
const names = ['session.jsonl', 'session.jsonl.zstd'];
|
|
190
|
+
for (let version = 1; version <= SESSION_FORMAT_VERSION; version += 1) {
|
|
191
|
+
names.push(`session.v${version}.jsonl`, `session.v${version}.jsonl.zstd`);
|
|
192
|
+
}
|
|
193
|
+
return names;
|
|
194
|
+
}
|
|
195
|
+
/** Canonical generation-log filenames as a lookup set (bare v0 or `vN`-suffixed, ± zstd). */
|
|
196
|
+
const SESSION_ARTIFACT_NAME_SET = new Set(sessionArtifactNames());
|
|
197
|
+
/** True for one canonical session-log artifact filename the backend may own. */
|
|
198
|
+
export function isSessionArtifactName(name) {
|
|
199
|
+
return SESSION_ARTIFACT_NAME_SET.has(name);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Guard a derived session directory before deletion (codex's scoped-path
|
|
203
|
+
* check, adapted to the JSONL layout): the directory's base name must be
|
|
204
|
+
* exactly `encodeSegment(id)` beneath its project grouping.
|
|
205
|
+
* @param dir - the derived session artifact directory.
|
|
206
|
+
* @param id - the session id the directory claims to belong to.
|
|
207
|
+
* @returns the guarded directory, or undefined when the layout is unexpected.
|
|
208
|
+
*/
|
|
209
|
+
export function sessionArtifactDirectory(dir, id) {
|
|
210
|
+
if (basename(dir) !== encodeSessionSegment(id))
|
|
211
|
+
return undefined;
|
|
212
|
+
if (basename(dirname(dir)) === NO_CWD_DIRECTORY)
|
|
213
|
+
return dir;
|
|
214
|
+
return /^--.*--$|^~/.test(basename(dirname(dir))) ? dir : undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The JSONL backend's configured session root, when the mounted backend
|
|
218
|
+
* exposes one. The upstream service contract dropped `locate()` in 0.1.5
|
|
219
|
+
* (artifact paths are backend-private; only refusal diagnostics carry them),
|
|
220
|
+
* so the TUI derives artifact paths from the backend's public plugin config.
|
|
221
|
+
* Backends without a JSONL-style config (or a foreign shape) yield undefined
|
|
222
|
+
* and callers degrade: mtime sorting falls back to createdAt and /delete
|
|
223
|
+
* refuses, exactly as before.
|
|
224
|
+
*/
|
|
225
|
+
export function jsonlSessionRoot(persistence) {
|
|
226
|
+
const root = persistence?.config?.root;
|
|
227
|
+
return typeof root === 'string' && root !== '' ? root : undefined;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Collect one session's deletion subtree: the id plus every record whose
|
|
231
|
+
* parent chain leads to it (codex deletes subagent threads with their root).
|
|
232
|
+
* @param records - the full directory listing.
|
|
233
|
+
* @param id - the root session id to delete.
|
|
234
|
+
* @returns the ids to delete, root first.
|
|
235
|
+
*/
|
|
236
|
+
export function collectDeletionSubtree(records, id) {
|
|
237
|
+
const parentOf = new Map();
|
|
238
|
+
for (const record of records)
|
|
239
|
+
parentOf.set(record.header.id, record.header.parentSession);
|
|
240
|
+
const doomed = new Set([id]);
|
|
241
|
+
// Iterate to a fixed point: children may be listed before their parents.
|
|
242
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
243
|
+
for (const candidate of parentOf.keys()) {
|
|
244
|
+
if (doomed.has(candidate))
|
|
245
|
+
continue;
|
|
246
|
+
let ancestor = parentOf.get(candidate);
|
|
247
|
+
let depth = 0;
|
|
248
|
+
while (ancestor !== undefined && depth < 64) {
|
|
249
|
+
if (doomed.has(ancestor)) {
|
|
250
|
+
doomed.add(candidate);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
ancestor = parentOf.get(ancestor);
|
|
254
|
+
depth += 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return [...doomed];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Plan one session-subtree deletion with NO filesystem side effects: collect
|
|
262
|
+
* the doomed lineage, refuse when the root or ANY member is live (a live
|
|
263
|
+
* child would outlive its deleted parent) or missing from the listing, and
|
|
264
|
+
* order the result children-first so the executor can never leave a deleted
|
|
265
|
+
* parent behind surviving children. Artifact-location guards stay at the
|
|
266
|
+
* call site; this is the pure preflight they complete.
|
|
267
|
+
* @param records - the full directory listing.
|
|
268
|
+
* @param id - the root session id to delete.
|
|
269
|
+
* @returns the ordered plan, or a user-facing refusal reason.
|
|
270
|
+
*/
|
|
271
|
+
export function planSessionDeletion(records, id) {
|
|
272
|
+
const target = records.find(record => record.header.id === id);
|
|
273
|
+
if (target === undefined)
|
|
274
|
+
return { ok: false, reason: `no persisted session matches "${id}"` };
|
|
275
|
+
if (target.live)
|
|
276
|
+
return { ok: false, reason: 'cannot delete a live session — it is open in this or another process' };
|
|
277
|
+
const doomed = collectDeletionSubtree(records, id);
|
|
278
|
+
const byId = new Map(records.map(record => [record.header.id, record]));
|
|
279
|
+
const parentOf = new Map();
|
|
280
|
+
for (const record of records)
|
|
281
|
+
parentOf.set(record.header.id, record.header.parentSession);
|
|
282
|
+
const nodes = [];
|
|
283
|
+
for (const candidate of doomed) {
|
|
284
|
+
const record = byId.get(candidate);
|
|
285
|
+
if (record === undefined)
|
|
286
|
+
return { ok: false, reason: `no persisted session matches "${candidate}"` };
|
|
287
|
+
if (record.live) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
reason: `cannot delete: child session ${candidate.slice(-12)} is live — close it (and any process using it) first`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
let depth = 0;
|
|
294
|
+
let ancestor = parentOf.get(candidate);
|
|
295
|
+
while (ancestor !== undefined && depth < 64) {
|
|
296
|
+
depth += 1;
|
|
297
|
+
ancestor = parentOf.get(ancestor);
|
|
298
|
+
}
|
|
299
|
+
nodes.push({ id: candidate, depth });
|
|
300
|
+
}
|
|
301
|
+
// Deepest first: a mid-deletion failure then leaves the shallowest lineage
|
|
302
|
+
// intact, never a deleted parent with surviving children.
|
|
303
|
+
nodes.sort((left, right) => right.depth - left.depth);
|
|
304
|
+
return { ok: true, nodes };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Codex-style relative time for session rows ("now", "5m ago", "3h ago",
|
|
308
|
+
* "2d ago"; older than a week falls back to the local date).
|
|
309
|
+
* @param timestamp - epoch milliseconds of the last activity.
|
|
310
|
+
* @param now - the pinned reference clock (one value per list render).
|
|
311
|
+
*/
|
|
312
|
+
export function formatRelativeTime(timestamp, now) {
|
|
313
|
+
const seconds = Math.round((now - timestamp) / 1000);
|
|
314
|
+
if (seconds < 60)
|
|
315
|
+
return t('time.justNow');
|
|
316
|
+
const minutes = Math.round(seconds / 60);
|
|
317
|
+
if (minutes < 60)
|
|
318
|
+
return t('time.minutesAgo', { n: minutes });
|
|
319
|
+
const hours = Math.round(minutes / 60);
|
|
320
|
+
if (hours < 24)
|
|
321
|
+
return t('time.hoursAgo', { n: hours });
|
|
322
|
+
const days = Math.round(hours / 24);
|
|
323
|
+
if (days < 7)
|
|
324
|
+
return t('time.daysAgo', { n: days });
|
|
325
|
+
const date = new Date(timestamp);
|
|
326
|
+
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
|
327
|
+
const day = `${date.getDate()}`.padStart(2, '0');
|
|
328
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
329
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skip-tolerant session-query engine for this terminal.
|
|
3
|
+
*
|
|
4
|
+
* The upstream SqliteSessionQueryEngine reconciliation observes EVERY
|
|
5
|
+
* persisted session before each search, and ONE unreadable source (for
|
|
6
|
+
* example a pre-release session artifact the frozen format codecs reject)
|
|
7
|
+
* fails the whole pass with SESSION_QUERY_PERSISTENCE_FAILED — every
|
|
8
|
+
* cross-session search dies because of a single old file nobody opened
|
|
9
|
+
* otherwise. This subclass overrides only the observation loop so an
|
|
10
|
+
* unreadable source is skipped with a warning and the rest of the corpus
|
|
11
|
+
* indexes normally; skipped sessions are retried on later reconciliations
|
|
12
|
+
* and rejoin automatically once a host that can read them is installed.
|
|
13
|
+
*
|
|
14
|
+
* Vendored surface note: `_observeStable` and its module-local helpers are
|
|
15
|
+
* private upstream; this file re-declares the observation loop against the
|
|
16
|
+
* pinned @deepseek-ai line (see package.json peers) and must be re-checked
|
|
17
|
+
* whenever that line moves. The engine class and the tool boundary also
|
|
18
|
+
* share ONE physical parent-package instance through this bundle, which
|
|
19
|
+
* restores instanceof-based typed error messages on the search path.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite';
|
|
23
|
+
import { assertSessionHeadersCompatible, buildSessionEventSearchDocuments, readColdSessionLog, SessionQueryError, } from '@deepseek-ai/dsh-session-query';
|
|
24
|
+
const STABLE_OBSERVATION_ATTEMPTS = 2;
|
|
25
|
+
function assertNotAborted(signal) {
|
|
26
|
+
if (signal?.aborted) {
|
|
27
|
+
throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isAbort(error) {
|
|
31
|
+
return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED';
|
|
32
|
+
}
|
|
33
|
+
function errorMessage(error) {
|
|
34
|
+
return error instanceof Error ? error.message : 'unknown error';
|
|
35
|
+
}
|
|
36
|
+
function observeSession(header, inheritedEventCount, events) {
|
|
37
|
+
const detachedHeader = structuredClone(header);
|
|
38
|
+
const detachedEvents = events.map(event => structuredClone(event));
|
|
39
|
+
return {
|
|
40
|
+
header: detachedHeader,
|
|
41
|
+
inheritedEventCount,
|
|
42
|
+
documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
|
|
43
|
+
fingerprint: createHash('sha256')
|
|
44
|
+
.update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents }))
|
|
45
|
+
.digest('base64url'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function sameHeader(a, b) {
|
|
49
|
+
return a.id === b.id
|
|
50
|
+
&& a.createdAt === b.createdAt
|
|
51
|
+
&& a.cwd === b.cwd
|
|
52
|
+
&& a.parentSession === b.parentSession
|
|
53
|
+
&& a.isSeeded === b.isSeeded
|
|
54
|
+
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
|
|
55
|
+
&& a.agentPreset === b.agentPreset;
|
|
56
|
+
}
|
|
57
|
+
function materializePersistenceSnapshots(snapshots) {
|
|
58
|
+
// `Array.isArray` narrows a declared array type to `any[]`, which would let
|
|
59
|
+
// every later access escape the type system. Step through `unknown` so the
|
|
60
|
+
// guard stays a runtime assertion and the element type stays declared.
|
|
61
|
+
const candidate = snapshots;
|
|
62
|
+
if (!Array.isArray(candidate))
|
|
63
|
+
throw new Error('persistence snapshots must be an array');
|
|
64
|
+
const result = new Map();
|
|
65
|
+
for (const snapshot of candidate) {
|
|
66
|
+
if (typeof snapshot.revision !== 'string') {
|
|
67
|
+
throw new Error('persistence snapshot revision must be a string');
|
|
68
|
+
}
|
|
69
|
+
const header = structuredClone(snapshot.header);
|
|
70
|
+
if (result.has(header.id)) {
|
|
71
|
+
throw new Error(`persistence listed duplicate session "${header.id}"`);
|
|
72
|
+
}
|
|
73
|
+
result.set(header.id, { header, revision: snapshot.revision });
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function samePersistenceSnapshots(before, after) {
|
|
78
|
+
if (before.size !== after.size)
|
|
79
|
+
return false;
|
|
80
|
+
for (const [id, first] of before) {
|
|
81
|
+
const second = after.get(id);
|
|
82
|
+
if (second === undefined || first.revision !== second.revision || !sameHeader(first.header, second.header))
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The skip-tolerant observation pass: structurally the upstream loop, with
|
|
89
|
+
* the per-source cold read wrapped so one unreadable session degrades to a
|
|
90
|
+
* warning instead of failing every search. Exported for unit tests with an
|
|
91
|
+
* injectable cold reader.
|
|
92
|
+
*/
|
|
93
|
+
export async function observeStableWithSkip(engine, indexed, signal, readCold = readColdSessionLog) {
|
|
94
|
+
for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) {
|
|
95
|
+
assertNotAborted(signal);
|
|
96
|
+
const persistenceBinding = engine._persistenceBinding;
|
|
97
|
+
const persistence = persistenceBinding.service;
|
|
98
|
+
const initiallyLive = new Set(engine.ctx.sessions.list().map(session => session.id));
|
|
99
|
+
let persisted = new Map();
|
|
100
|
+
if (persistence !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
const canReuseIndexed = engine._lastPersistenceIdentity === undefined
|
|
103
|
+
|| engine._lastPersistenceIdentity === persistenceBinding.identity;
|
|
104
|
+
const listOptions = signal === undefined ? undefined : { signal };
|
|
105
|
+
const before = await persistence.list(listOptions);
|
|
106
|
+
assertNotAborted(signal);
|
|
107
|
+
persisted = materializePersistenceSnapshots(before);
|
|
108
|
+
for (const entry of persisted.values()) {
|
|
109
|
+
if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision)
|
|
110
|
+
continue;
|
|
111
|
+
if (initiallyLive.has(entry.header.id) || engine.ctx.sessions.get(entry.header.id) !== undefined)
|
|
112
|
+
continue;
|
|
113
|
+
assertNotAborted(signal);
|
|
114
|
+
// The skip: a session whose cold log fails to migrate or decode
|
|
115
|
+
// stays OUT of the index with one warning; the remaining corpus
|
|
116
|
+
// indexes normally. `loaded` stays undefined exactly like a
|
|
117
|
+
// not-yet-read entry, so the stable-snapshot comparison and the
|
|
118
|
+
// live-preferred merge below are unaffected.
|
|
119
|
+
try {
|
|
120
|
+
const loaded = await readCold(persistence, entry.header.id, signal);
|
|
121
|
+
assertNotAborted(signal);
|
|
122
|
+
assertSessionHeadersCompatible(entry.header, loaded.header);
|
|
123
|
+
entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (isAbort(error) || signal?.aborted)
|
|
127
|
+
throw error;
|
|
128
|
+
if (engine._persistenceBinding !== persistenceBinding)
|
|
129
|
+
break;
|
|
130
|
+
entry.unreadable = errorMessage(error);
|
|
131
|
+
engine.ctx.logger?.warn('session-search skipped unreadable session %s: %s', entry.header.id, entry.unreadable);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assertNotAborted(signal);
|
|
135
|
+
const afterSnapshots = await persistence.list(listOptions);
|
|
136
|
+
assertNotAborted(signal);
|
|
137
|
+
const after = materializePersistenceSnapshots(afterSnapshots);
|
|
138
|
+
if (!samePersistenceSnapshots(persisted, after))
|
|
139
|
+
continue;
|
|
140
|
+
if (engine._persistenceBinding !== persistenceBinding)
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (isAbort(error) || signal?.aborted) {
|
|
145
|
+
throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { cause: error });
|
|
146
|
+
}
|
|
147
|
+
if (engine._persistenceBinding !== persistenceBinding)
|
|
148
|
+
continue;
|
|
149
|
+
if (error instanceof SessionQueryError)
|
|
150
|
+
throw error;
|
|
151
|
+
throw new SessionQueryError(`session-search persistence observation failed: ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const live = new Map();
|
|
155
|
+
for (const session of engine.ctx.sessions.list()) {
|
|
156
|
+
const observed = observeSession(session.header, session.inheritedEventCount, session.snapshotEvents());
|
|
157
|
+
const durable = persisted.get(session.id);
|
|
158
|
+
if (durable !== undefined && durable.loaded === undefined) {
|
|
159
|
+
// A live owner always wins over a skipped durable copy.
|
|
160
|
+
live.set(session.id, observed);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (durable !== undefined)
|
|
164
|
+
assertSessionHeadersCompatible(observed.header, durable.header);
|
|
165
|
+
live.set(session.id, observed);
|
|
166
|
+
}
|
|
167
|
+
const sameLive = initiallyLive.size === live.size && [...initiallyLive].every(id => live.has(id));
|
|
168
|
+
if (!sameLive)
|
|
169
|
+
continue;
|
|
170
|
+
return { persistenceBinding, persisted, live };
|
|
171
|
+
}
|
|
172
|
+
throw new SessionQueryError('session-search persistence observation did not stabilize after one retry', 'SESSION_QUERY_PERSISTENCE_FAILED');
|
|
173
|
+
}
|
|
174
|
+
// The opaque-base cast keeps the private `_observeStable` override legal in
|
|
175
|
+
// TypeScript while inheriting every runtime static (inject, Config, Service
|
|
176
|
+
// metadata) from the real engine class.
|
|
177
|
+
const EngineBase = SqliteSessionQueryEngine;
|
|
178
|
+
/** The engine this bundle mounts in place of the base `session-query-sqlite` row. */
|
|
179
|
+
export class SkipTolerantSessionQueryEngine extends EngineBase {
|
|
180
|
+
async _observeStable(indexed, signal) {
|
|
181
|
+
return await observeStableWithSkip(this, indexed, signal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export default SkipTolerantSessionQueryEngine;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Latest-wins, idle-bound queue for safe Agent session changes. */
|
|
2
|
+
export class SessionSwitchQueue {
|
|
3
|
+
pending;
|
|
4
|
+
pumping = false;
|
|
5
|
+
execute;
|
|
6
|
+
failed;
|
|
7
|
+
constructor(execute, failed) {
|
|
8
|
+
this.execute = execute;
|
|
9
|
+
this.failed = failed;
|
|
10
|
+
}
|
|
11
|
+
/** Queue a request; a later request replaces any request still waiting. */
|
|
12
|
+
request(activity, value) {
|
|
13
|
+
this.pending = { activity, value };
|
|
14
|
+
const outcome = activity.status === 'running' || this.pumping ? 'queued' : 'started';
|
|
15
|
+
if (!this.pumping)
|
|
16
|
+
void this.pump();
|
|
17
|
+
return outcome;
|
|
18
|
+
}
|
|
19
|
+
/** Cancel only work that has not begun activation. */
|
|
20
|
+
cancel() {
|
|
21
|
+
if (this.pending === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
this.pending = undefined;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
async pump() {
|
|
27
|
+
this.pumping = true;
|
|
28
|
+
try {
|
|
29
|
+
while (this.pending !== undefined) {
|
|
30
|
+
const observed = this.pending;
|
|
31
|
+
await observed.activity.whenIdle();
|
|
32
|
+
// Another request replaced this one while the turn was converging.
|
|
33
|
+
if (this.pending !== observed)
|
|
34
|
+
continue;
|
|
35
|
+
this.pending = undefined;
|
|
36
|
+
try {
|
|
37
|
+
await this.execute(observed.value);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.failed(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
this.pumping = false;
|
|
46
|
+
// A request may land between the loop condition and finally.
|
|
47
|
+
if (this.pending !== undefined)
|
|
48
|
+
void this.pump();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialized, crash-atomic persistence for the small user-level JSON files
|
|
3
|
+
* under the DSH home (statusline.json, theme.json). Two guarantees the bare
|
|
4
|
+
* floating `writeFile` path could not give:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every save is appended to ONE chain, so rapid consecutive edits land
|
|
7
|
+
* in submission order and the last snapshot is the one on disk (parallel
|
|
8
|
+
* floating writes let an older snapshot finish last and win).
|
|
9
|
+
* 2. Each write goes to a sibling temp file first and is renamed into
|
|
10
|
+
* place, so a crash mid-write can never leave a half-written JSON
|
|
11
|
+
* document behind.
|
|
12
|
+
*
|
|
13
|
+
* The chain itself never rejects: a failed write is reported to that
|
|
14
|
+
* save's caller while later saves keep their turn.
|
|
15
|
+
*
|
|
16
|
+
* @module @deepseek-ai/dsh-code/settings-file
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
/**
|
|
22
|
+
* Run one file operation with a bounded retry: one initial try plus at
|
|
23
|
+
* most `retries` more. Creating or replacing a file can fail transiently
|
|
24
|
+
* with EPERM/EACCES while an antivirus scanner or search indexer holds
|
|
25
|
+
* it — the standard graceful-fs remedy, not a workaround for a
|
|
26
|
+
* persistent permission problem. A save that still fails leaves its
|
|
27
|
+
* uniquely named temp file behind, so repeated crashed saves accumulate
|
|
28
|
+
* distinct leftovers rather than corrupting a shared one.
|
|
29
|
+
*/
|
|
30
|
+
async function withTransientRetry(operation, retries = 5) {
|
|
31
|
+
for (let attempt = 0;; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
await operation();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const code = error.code;
|
|
38
|
+
if (attempt >= retries || (code !== 'EPERM' && code !== 'EACCES'))
|
|
39
|
+
throw error;
|
|
40
|
+
await new Promise(resolve => setTimeout(resolve, 30 * (attempt + 1)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Write one file atomically: create the parent directory, write to a
|
|
46
|
+
* uniquely named temp file, and rename it into place. A crash midway
|
|
47
|
+
* can never leave a half-written document behind. Unique temp names
|
|
48
|
+
* keep concurrent writers (two terminals, two chains in one process)
|
|
49
|
+
* from sharing one temp path.
|
|
50
|
+
*/
|
|
51
|
+
export async function writeFileAtomically(path, text) {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
54
|
+
await withTransientRetry(() => writeFile(temp, text, 'utf8'));
|
|
55
|
+
await withTransientRetry(() => rename(temp, path));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create the shared settings-write chain. One instance per process keeps
|
|
59
|
+
* every user-level JSON file mutually serialized.
|
|
60
|
+
* @returns the persistence handle.
|
|
61
|
+
*/
|
|
62
|
+
export function createUserSettingsPersistence() {
|
|
63
|
+
let chain = Promise.resolve();
|
|
64
|
+
return {
|
|
65
|
+
save(path, text) {
|
|
66
|
+
const write = chain.then(() => writeFileAtomically(path, text));
|
|
67
|
+
// A failed write must not break the chain for later saves.
|
|
68
|
+
chain = write.catch(() => { });
|
|
69
|
+
return write;
|
|
70
|
+
},
|
|
71
|
+
flush() {
|
|
72
|
+
return chain;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|