@aiwayds/dsh-tui-pi 0.28.0 → 1.0.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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Single-writer guard — a vendored, zero-dependency per-session pid-file
3
+ * lock, byte-for-byte the same contract the feishu plugin's guard uses so
4
+ * both surfaces compete on one file.
5
+ *
6
+ * Why it exists: two dsh processes pointed at the same profile each keep
7
+ * their own agent registry; `agents.get` cannot see the other process's
8
+ * live agent. A cold resume there therefore forks the log: two agents
9
+ * append to one jsonl and the interleaved seq numbers make the loader
10
+ * reject the session ("corrupt session log"). The lock sits NEXT to the
11
+ * log (`<sessionDir>/writer.lock`), derived with the exact path encoding
12
+ * upstream's jsonl backend uses, so every process about to create or
13
+ * cold-resume competes on the same file.
14
+ *
15
+ * Claiming protocol (concurrency-critical):
16
+ * - The payload is written FULLY to `<lock>.tmp-<pid>-<uniq>` first, then
17
+ * claimed with `link(2)` — atomic at the kernel level, never writable
18
+ * half-way, so no observer can read an empty/partial lock file.
19
+ * - Two processes that both judge the same residue dead cannot both win:
20
+ * they race ONE link(); the loser sees EEXIST and re-evaluates against
21
+ * the WINNER's fresh live pid, then refuses. Stealing (renaming the dead
22
+ * residue aside) is merely garbage collection of readable history — the
23
+ * exclusive right is granted by link(), not by who cleared what.
24
+ * - PID reuse can misreport a dead lock as alive; removing such a residual
25
+ * stays an explicit manual act (delete the file), never automatic.
26
+ */
27
+ import { link, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
28
+ import { randomBytes } from 'node:crypto';
29
+ import { join, resolve } from 'node:path';
30
+ /** Thrown when a cold arm refused to open a second writer for a session. */
31
+ export class WriterLockedError extends Error {
32
+ holder;
33
+ constructor(holder) {
34
+ super(`session is locked by a live process (pid ${holder.pid}`
35
+ + `${holder.createdAt !== '' ? `, since ${holder.createdAt}` : ''})`
36
+ + ' — refusing to fork the log');
37
+ this.name = 'WriterLockedError';
38
+ this.holder = holder;
39
+ }
40
+ }
41
+ const LOCK_HOLDER = 'tui';
42
+ /** Contention budget across residue-clearing rounds before giving up loudly. */
43
+ const MAX_CLAIM_ROUNDS = 4;
44
+ /**
45
+ * Dirs where THIS process established (created or stole) the lock. Only an
46
+ * establishing acquisition may release the file: a cooperative re-acquire
47
+ * (same pid already recorded) inherits it without removal rights.
48
+ */
49
+ const established = new Set();
50
+ export function writerLockPath(dir) {
51
+ return join(dir, 'writer.lock');
52
+ }
53
+ /**
54
+ * Project-directory name under which the jsonl backend groups a cwd's
55
+ * sessions — byte-identical port of upstream `projectKey()` in
56
+ * @deepseek-ai/dsh-session-persistence-jsonl (also mirrored by the feishu
57
+ * plugin's guard). The derived session dir additionally relies on upstream's
58
+ * `encodeSegment(sessionId)` being the IDENTITY for dsh ids — true while ids
59
+ * are UUIDs (safe charset ⊇ UUID charset). If upstream ever introduces
60
+ * non-UUID ids, port encodeSegment too or locks land beside decoy paths;
61
+ * update only in lockstep with upstream either way.
62
+ */
63
+ export function projectKeyFor(cwd) {
64
+ if (cwd.length === 0)
65
+ throw new Error('cannot encode an empty project path');
66
+ let readable = '';
67
+ let separatorRun = false;
68
+ for (let i = 0; i < cwd.length; i++) {
69
+ const code = cwd.charCodeAt(i);
70
+ const ch = String.fromCharCode(code);
71
+ if (ch === '/' || ch === '\\' || ch === ':') {
72
+ if (!separatorRun)
73
+ readable += '-';
74
+ separatorRun = true;
75
+ }
76
+ else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
77
+ readable += ch;
78
+ separatorRun = false;
79
+ }
80
+ else {
81
+ readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
82
+ separatorRun = false;
83
+ }
84
+ }
85
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
86
+ }
87
+ function processAlive(pid) {
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ }
92
+ catch (error) {
93
+ // EPERM means the process exists but belongs to another user — alive.
94
+ return error.code === 'EPERM';
95
+ }
96
+ }
97
+ async function readHolder(path) {
98
+ let raw;
99
+ try {
100
+ raw = await readFile(path, 'utf8');
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ try {
106
+ const parsed = JSON.parse(raw);
107
+ if (!Number.isInteger(parsed.pid) || parsed.pid <= 0)
108
+ return undefined;
109
+ return {
110
+ pid: parsed.pid,
111
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : '',
112
+ holder: typeof parsed.holder === 'string' ? parsed.holder : LOCK_HOLDER,
113
+ };
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ /**
120
+ * Fully write the payload to a private temp file, then CLAIM the lock name
121
+ * with link() — atomic: exactly one contender wins; everyone else gets
122
+ * EEXIST and re-reads whoever actually holds the name. Returns true iff WE
123
+ * claimed it (and thus own its release).
124
+ */
125
+ async function claimFresh(path) {
126
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
127
+ await writeFile(tmp, JSON.stringify({
128
+ pid: process.pid,
129
+ createdAt: new Date().toISOString(),
130
+ holder: LOCK_HOLDER,
131
+ }));
132
+ try {
133
+ try {
134
+ await link(tmp, path);
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ if (error.code === 'EEXIST')
139
+ return false;
140
+ throw error;
141
+ }
142
+ }
143
+ finally {
144
+ await unlink(tmp).catch(() => { });
145
+ }
146
+ }
147
+ async function clearResidue(path) {
148
+ try {
149
+ await rename(path, `${path}.stale-${Date.now()}-${randomBytes(3).toString('hex')}`);
150
+ }
151
+ catch (error) {
152
+ // Another contender cleared (or replaced) it already — harmless; the
153
+ // caller simply loops and re-reads the CURRENT state of the name.
154
+ if (error.code !== 'ENOENT')
155
+ throw error;
156
+ }
157
+ }
158
+ /**
159
+ * Acquire the single-writer lock for `dir`. Fresh creation or a cleared
160
+ * residue establishes ownership (releasable later); a same-pid token is a
161
+ * cooperative re-acquire — success WITHOUT rewriting the existing file and
162
+ * without release rights. A lock held by another LIVE process refuses with
163
+ * its holder info.
164
+ */
165
+ export async function acquireWriterLock(dir) {
166
+ await mkdir(dir, { recursive: true });
167
+ const path = writerLockPath(dir);
168
+ for (let round = 0; round < MAX_CLAIM_ROUNDS; round++) {
169
+ if (await claimFresh(path)) {
170
+ established.add(resolve(dir));
171
+ return { ok: true };
172
+ }
173
+ const holder = await readHolder(path);
174
+ if (holder !== undefined && holder.pid === process.pid) {
175
+ // Cooperative re-acquire: inherit, do not rewrite, never releasable.
176
+ return { ok: true };
177
+ }
178
+ if (holder !== undefined && processAlive(holder.pid)) {
179
+ return { ok: false, holder };
180
+ }
181
+ // Dead-or-corrupt-or-raced-away holder: collect the residue and re-try
182
+ // the atomic claim; any live winner introduced meanwhile is re-read and
183
+ // respected on the next round.
184
+ await clearResidue(path);
185
+ }
186
+ fail(`writer.lock contention did not settle after ${MAX_CLAIM_ROUNDS} rounds: ${path}`);
187
+ }
188
+ function fail(message) {
189
+ throw new Error(message);
190
+ }
191
+ /**
192
+ * Release the lock on `dir`, but ONLY when this process established it.
193
+ * A cooperative/inherited token — or a dir we never touched — is untouched.
194
+ */
195
+ export async function releaseOwnedWriterLock(dir) {
196
+ const key = resolve(dir);
197
+ if (!established.delete(key))
198
+ return;
199
+ try {
200
+ await unlink(writerLockPath(dir));
201
+ }
202
+ catch {
203
+ // Already gone (or raced away) — nothing left to clean.
204
+ }
205
+ }
206
+ //# sourceMappingURL=writer-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writer-lock.js","sourceRoot":"","sources":["../src/writer-lock.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-tui-pi",
3
- "version": "0.28.0",
3
+ "version": "1.0.0",
4
4
  "description": "pi-style terminal UI for DeepSeek Harness (dsh) — pi-tui look & feel, dsh slash commands, GitHub light/dark themes, powerline footer",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repair-session-log.mjs — offline surgery for session logs corrupted by
4
+ * concurrent writers. When two dsh processes append to one session log
5
+ * (the cross-process double-resume incident) the storage rows interleave
6
+ * two seq sequences and the loader refuses the log: "corrupt session log:
7
+ * seq gap in committed region …".
8
+ *
9
+ * Storage model (mirrors @deepseek-ai/dsh-session packChunkRuns +
10
+ * dsh-session-persistence-jsonl framing):
11
+ * `<name>.jsonl.zstd` = concatenated independent zstd frames, each holding
12
+ * ≥1 storage rows joined by "\n". A row is either
13
+ * - a plain event `{type, seq?, time?, data}` (seq absent only on the very
14
+ * first identity rows), or
15
+ * - a packed delta run `{type:"text-chunks"|"reasoning-chunks"|
16
+ * "tool-call-chunks", seq0, time0, data:{…, dt:[…]}}`, occupying the seq
17
+ * span `[seq0, seq0 + payload.length - 1]`.
18
+ *
19
+ * Repair policy (deterministic, conservative):
20
+ * 1. Identity rows without seq are kept verbatim at the top.
21
+ * 2. Every seq-bearing row maps onto its span; rows are then processed in
22
+ * ascending (seqStart, originalIndex) order — duplicate COVERAGE is
23
+ * dropped ("loser writer" copies arrive behind the same slots), while
24
+ * every event slot never seen before survives, whichever writer wrote it.
25
+ * A row that only PARTIALLY overlaps already-covered slots is still
26
+ * kept whole (its fresh slots win; the shadowed middle slots stay
27
+ * duplicated inside the repaired log) — splitting packed rows is
28
+ * deliberately deferred; such rows are reported explicitly.
29
+ * 3. Survivors are renumbered into one dense seq space (packed rows keep
30
+ * their internal span width) and written to `<name>.repaired.jsonl[.zstd]`
31
+ * re-encoded in the writer's multi-frame shape — frame 1 carries the
32
+ * header line alone, remaining rows chunked 512 per frame — so the
33
+ * output loads in dsh directly (a single-frame rewrite would be
34
+ * rejected with "first frame is not exactly one header line").
35
+ * beside the original. The original is NEVER modified or deleted; to
36
+ * actually LOAD the repair you must move it over the corrupted name
37
+ * yourself (the success line prints the exact mv command).
38
+ *
39
+ * Torn/truncated lines (another artifact of concurrent appends) abort the
40
+ * run unless --skip-bad-lines is given, which drops and reports them.
41
+ *
42
+ * Usage:
43
+ * node scripts/repair-session-log.mjs <log.jsonl.zstd | log.jsonl> [--apply] [--skip-bad-lines]
44
+ *
45
+ * Exit codes: 0 clean/repaired · 3 corrupt (diagnosed; repaired only with
46
+ * --apply) · 2 usage/environment/unparseable-input error. Stop the processes
47
+ * driving the live session BEFORE repairing — this fixes bytes, not agents.
48
+ */
49
+
50
+ import { spawnSync } from 'node:child_process'
51
+ import { createHash } from 'node:crypto'
52
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
53
+ import { basename, dirname, join } from 'node:path'
54
+
55
+ const PACKED_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
56
+
57
+ function fail(message) {
58
+ console.error(`repair-session-log: ${message}`)
59
+ process.exit(2)
60
+ }
61
+
62
+ function parseRow(line, index) {
63
+ let value
64
+ try {
65
+ value = JSON.parse(line)
66
+ } catch {
67
+ return undefined // caller decides: fatal without --skip-bad-lines
68
+ }
69
+ const type = typeof value.type === 'string' ? value.type : '(none)'
70
+ const row = { index, line, type, start: null, end: null, slots: 0, hash: '' }
71
+ const payloadLen = () => {
72
+ const data = value.data ?? {}
73
+ const payload = Array.isArray(data.texts) ? data.texts : Array.isArray(data.args) ? data.args : []
74
+ return Math.max(payload.length, 1)
75
+ }
76
+ if (PACKED_TYPES.has(type)) {
77
+ if (!Number.isSafeInteger(value.seq0)) return row // treated as identity-row companion
78
+ row.slots = payloadLen()
79
+ row.start = value.seq0
80
+ row.end = value.seq0 + row.slots - 1
81
+ } else if (Number.isSafeInteger(value.seq)) {
82
+ row.slots = 1
83
+ row.start = value.seq
84
+ row.end = value.seq
85
+ }
86
+ row.hash = createHash('sha256').update(line).digest('hex').slice(0, 12)
87
+ return row
88
+ }
89
+
90
+ function analyze(rows) {
91
+ let prevEnd = -1
92
+ const violations = []
93
+ for (const r of rows) {
94
+ if (r.start === null) continue
95
+ if (r.start <= prevEnd) {
96
+ violations.push({ index: r.index, start: r.start, end: r.end, prevEnd, type: r.type })
97
+ }
98
+ if (r.end > prevEnd) prevEnd = r.end
99
+ }
100
+ return violations
101
+ }
102
+
103
+ /**
104
+ * Survivor selection: identity rows ride along; seq rows sorted by
105
+ * (start, originalIndex) keep every previously-unseen slot. Deterministic
106
+ * across runs, never biased toward a particular writer. Rows whose span
107
+ * crosses the frontier are kept whole AND reported (their shadowed middle
108
+ * slots stay duplicated; see the header note on splitting).
109
+ */
110
+ function selectSurvivors(rows) {
111
+ const identity = rows.filter(r => r.start === null)
112
+ const carriers = rows.filter(r => r.start !== null).sort((a, b) => a.start - b.start || a.index - b.index)
113
+ const survivors = []
114
+ const dropped = []
115
+ const partialOverlaps = []
116
+ let frontier = -1
117
+ for (const r of carriers) {
118
+ if (r.end <= frontier) {
119
+ dropped.push({ index: r.index, start: r.start, end: r.end, type: r.type, hash: r.hash })
120
+ continue
121
+ }
122
+ if (r.start <= frontier && r.end > frontier) {
123
+ partialOverlaps.push({ index: r.index, start: r.start, end: r.end })
124
+ }
125
+ survivors.push(r)
126
+ frontier = r.end
127
+ }
128
+ return { identity, survivors, dropped, partialOverlaps }
129
+ }
130
+
131
+ /** Dense renumbering in survivor emission order. */
132
+ function renumber(identity, survivors) {
133
+ const out = [...identity.map(r => r.line)]
134
+ let next = 0
135
+ for (const r of survivors) {
136
+ const value = JSON.parse(r.line)
137
+ if (PACKED_TYPES.has(r.type)) {
138
+ value.seq0 = next
139
+ next += r.slots
140
+ } else {
141
+ value.seq = next
142
+ next += 1
143
+ }
144
+ out.push(JSON.stringify(value))
145
+ }
146
+ return { lines: out, lastIndex: next - 1 }
147
+ }
148
+
149
+ function decompress(file) {
150
+ const proc = spawnSync('zstd', ['-dc', file], { maxBuffer: 1 << 30 })
151
+ if (proc.error) fail(`cannot run zstd: ${proc.error.message}`)
152
+ if (proc.status !== 0) fail(`zstd -dc failed on ${file}: ${String(proc.stderr).slice(0, 200)}`)
153
+ return proc.stdout.toString('utf8')
154
+ }
155
+
156
+ function compressToFrames(lines, outPathTmp) {
157
+ // dsh's jsonl backend reads frame 1 as exactly one header line and frames
158
+ // 2+ as row batches; a single-frame rewrite of the whole body loads as
159
+ // "first frame is not exactly one header line". Frame 1 = the identity
160
+ // line alone, remaining rows chunked FRAME_ROWS per frame.
161
+ const FRAME_ROWS = 512
162
+ const chunks = [lines.slice(0, 1)]
163
+ for (let i = 1; i < lines.length; i += FRAME_ROWS) chunks.push(lines.slice(i, i + FRAME_ROWS))
164
+ const parts = chunks.map(chunk => {
165
+ const proc = spawnSync('zstd', ['-19'], { input: Buffer.from(`${chunk.join('\n')}\n`, 'utf8'), maxBuffer: 1 << 30 })
166
+ if (proc.error) fail(`cannot run zstd: ${proc.error.message}`)
167
+ if (proc.status !== 0) fail(`zstd compress failed: ${String(proc.stderr).slice(0, 200)}`)
168
+ return proc.stdout
169
+ })
170
+ writeFileSync(outPathTmp, Buffer.concat(parts))
171
+ }
172
+
173
+ function main() {
174
+ const args = process.argv.slice(2)
175
+ const apply = args.includes('--apply')
176
+ const skipBadLines = args.includes('--skip-bad-lines')
177
+ const positional = args.filter(a => !a.startsWith('--'))
178
+ if (positional.length !== 1) {
179
+ fail('usage: repair-session-log.mjs <log.jsonl.zstd|log.jsonl> [--apply] [--skip-bad-lines]')
180
+ }
181
+ const file = positional[0]
182
+
183
+ const isZstd = file.endsWith('.jsonl.zstd')
184
+ const text = isZstd ? decompress(file) : readFileSync(file, 'utf8')
185
+ const rawLines = text.split('\n').filter(l => l.trim() !== '')
186
+ const rows = []
187
+ const badLines = []
188
+ for (let i = 0; i < rawLines.length; i++) {
189
+ const parsed = parseRow(rawLines[i], i)
190
+ if (parsed === undefined) {
191
+ badLines.push({ index: i + 1, preview: rawLines[i].slice(0, 120) })
192
+ continue
193
+ }
194
+ rows.push(parsed)
195
+ }
196
+ if (badLines.length > 0 && !skipBadLines) {
197
+ for (const bad of badLines.slice(0, 5)) {
198
+ console.error(`repair-session-log: line ${bad.index} unparseable: ${bad.preview}`)
199
+ }
200
+ fail(`${badLines.length} torn/unparseable line(s) — re-run with --skip-bad-lines to drop and report them`)
201
+ }
202
+
203
+ const violations = analyze(rows)
204
+ const maxSeq = rows.reduce((m, r) => Math.max(m, r.end ?? -1), -1)
205
+
206
+ console.log(`rows: ${rows.length}${badLines.length > 0 ? ` (+${badLines.length} skipped bad lines)` : ''} | maxSeq: ${maxSeq} | seq violations: ${violations.length}`)
207
+ for (const v of violations.slice(0, 10)) {
208
+ console.log(` ✗ line ${v.index + 1}: ${v.type} covers ${v.start}..${v.end} after coverage through ${v.prevEnd}`)
209
+ }
210
+ if (violations.length > 10) console.log(` … and ${violations.length - 10} more`)
211
+
212
+ if (violations.length === 0) {
213
+ console.log('verdict: CLEAN — nothing to do.')
214
+ return
215
+ }
216
+
217
+ const { identity, survivors, dropped, partialOverlaps } = selectSurvivors(rows)
218
+ console.log(`plan: keep ${identity.length} identity + ${survivors.length} event rows (${dropped.length} duplicate-coverage rows dropped), renumber dense.`)
219
+ for (const d of dropped.slice(0, 10)) {
220
+ console.log(` − line ${d.index + 1}: ${d.type} ${d.start}..${d.end} (shadowed copy [${d.hash}])`)
221
+ }
222
+ for (const p of partialOverlaps.slice(0, 10)) {
223
+ console.log(` ⚠ line ${p.index + 1}: spans ${p.start}..${p.end}, partially overlapping covered slots — kept whole; the shadowed middle slots stay duplicated in the repaired log`)
224
+ }
225
+
226
+ if (!apply) {
227
+ console.log('dry-run: nothing written. Re-run with --apply to write *.repaired beside the original.')
228
+ process.exit(3)
229
+ }
230
+
231
+ const { lines: fixedLines, lastIndex } = renumber(identity, survivors)
232
+ const body = `${fixedLines.join('\n')}\n`
233
+ const check = analyze(body.split('\n').filter(l => l.trim() !== '').map(parseRow))
234
+ if (check.length > 0) fail('internal: repaired output still violates seq order — refusing to write')
235
+
236
+ const stem = basename(file).replace(/\.jsonl(\.zstd)?$/, '')
237
+ const outPath = join(dirname(file), `${stem}.repaired.jsonl${isZstd ? '.zstd' : ''}`)
238
+ const tmpPath = `${outPath}.tmp`
239
+ if (isZstd) compressToFrames(fixedLines, tmpPath)
240
+ else writeFileSync(tmpPath, body)
241
+ renameSync(tmpPath, outPath)
242
+ console.log(`wrote ${outPath} (${fixedLines.length} rows, dense seq 0..${lastIndex}). Original untouched.`)
243
+ console.log(`activate with: mv '${outPath}' '${file}' # only while no process is writing that session`)
244
+ }
245
+
246
+ main()