dsh-session-path 0.0.0-stage → 0.1.2

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,124 @@
1
+ /**
2
+ * Resolve everything a caller needs to know about one session: its id, title,
3
+ * workspace directory and the absolute path of its committed log.
4
+ *
5
+ * The workspace directory comes from the live session header when the session is
6
+ * mounted, and the log path comes from an authoritative directory lookup — never
7
+ * from a derived guess alone, so a renamed project folder cannot produce a path
8
+ * that does not exist.
9
+ *
10
+ * @module dsh-session-path/session-info
11
+ */
12
+
13
+ import { findSessionDirectory, sessionsRoot } from "./paths.js";
14
+ import { readSessionRecords } from "./zstd.js";
15
+
16
+ /** Longest accepted session id / title / cwd hint (untrusted input from the GUI). */
17
+ const MAX_HINT_CHARS = 4096;
18
+
19
+ /**
20
+ * The live session header, when the host's session store has the session.
21
+ *
22
+ * @param ctx - host plugin context.
23
+ * @param sessionId - session id.
24
+ * @returns the header object, or an empty object.
25
+ */
26
+ export function liveSessionHeader(ctx, sessionId) {
27
+ try {
28
+ const sessions = ctx?.get?.("sessions");
29
+ const session = typeof sessions?.get === "function" ? sessions.get(sessionId) : undefined;
30
+ const header = session?.header;
31
+ return header !== null && typeof header === "object" ? header : {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Normalise one untrusted string hint (or an empty string).
39
+ *
40
+ * @param value - candidate value.
41
+ * @returns the trimmed value within the length cap.
42
+ */
43
+ function hint(value) {
44
+ return typeof value === "string" && value.length > 0 ? value.slice(0, MAX_HINT_CHARS) : "";
45
+ }
46
+
47
+ /**
48
+ * The session's title from its log, taken from the last `session/title` event
49
+ * inside a bounded prefix of the file.
50
+ *
51
+ * @param file - log path.
52
+ * @param maxBytes - compressed byte budget.
53
+ * @returns the title, or an empty string.
54
+ */
55
+ export async function readSessionTitle(file, maxBytes) {
56
+ let title = "";
57
+ try {
58
+ for await (const record of readSessionRecords(file, { maxBytes })) {
59
+ if (record.type !== "session/title") continue;
60
+ const next = record.data?.title;
61
+ if (typeof next === "string" && next.trim().length > 0) title = next.trim();
62
+ }
63
+ } catch {
64
+ /* the title is cosmetic: a damaged log must not fail the whole call */
65
+ }
66
+ return title;
67
+ }
68
+
69
+ /**
70
+ * The session's own header record from the log (`{ type: "session", cwd, ... }`).
71
+ *
72
+ * A cold session — one this process never mounted — has no live header, and the
73
+ * workspace directory is what a continuation needs most. The header is the first
74
+ * record of the first frame, so a small byte budget is always enough.
75
+ *
76
+ * @param file - log path.
77
+ * @param maxBytes - compressed byte budget.
78
+ * @returns the header record, or null.
79
+ */
80
+ export async function readSessionHeader(file, maxBytes = 262_144) {
81
+ try {
82
+ for await (const record of readSessionRecords(file, { maxBytes })) {
83
+ if (record.type === "session") return record;
84
+ }
85
+ } catch {
86
+ /* fall through: an unknown cwd is reported as empty */
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Everything the tools and the HTTP route need about one session.
93
+ *
94
+ * @param ctx - host plugin context.
95
+ * @param settings - resolved plugin settings.
96
+ * @param sessionId - the session to resolve.
97
+ * @param hints - optional GUI-supplied `{ title, cwd }` (cosmetic, unverified).
98
+ * @returns the resolved session info.
99
+ */
100
+ export async function resolveSessionInfo(ctx, settings, sessionId, hints = {}) {
101
+ const id = hint(sessionId);
102
+ if (id === "") throw new Error("session id is required");
103
+ const header = liveSessionHeader(ctx, id);
104
+ const hintedCwd = hint(hints.cwd);
105
+ const liveCwd = hint(header.cwd);
106
+ const root = sessionsRoot(ctx);
107
+ const found = findSessionDirectory(root, id, liveCwd || hintedCwd);
108
+ const logs = found?.logs ?? [];
109
+ const logPath = logs[0] ?? "";
110
+ let cwd = liveCwd || hintedCwd;
111
+ if (cwd === "" && logPath !== "") cwd = hint((await readSessionHeader(logPath))?.cwd);
112
+ let title = hint(hints.title);
113
+ if (title === "" && logPath !== "") title = await readSessionTitle(logPath, settings.titleScanBytes);
114
+ return {
115
+ sessionId: id,
116
+ title,
117
+ cwd,
118
+ logPath,
119
+ dir: found?.dir ?? "",
120
+ project: found?.project ?? "",
121
+ root,
122
+ exists: logPath !== "",
123
+ };
124
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The two agent-facing tools.
3
+ *
4
+ * `session_path` answers "where is this session's log?" (no writes), and
5
+ * `session_handoff` answers "give me something I can actually read" by writing a
6
+ * decompressed transcript next to the DSH home. Together they are how one
7
+ * session hands its context to another.
8
+ *
9
+ * @module dsh-session-path/tools
10
+ */
11
+
12
+ import { composeCopyText } from "./copy-text.js";
13
+ import { writeHandoff } from "./handoff.js";
14
+ import { resolveSessionInfo } from "./session-info.js";
15
+
16
+ /**
17
+ * The session a call targets: the explicit argument, else the calling session.
18
+ *
19
+ * @param args - tool arguments.
20
+ * @param exec - tool execution context.
21
+ * @returns the session id.
22
+ */
23
+ function targetSessionId(args, exec) {
24
+ const explicit = typeof args?.sessionId === "string" ? args.sessionId.trim() : "";
25
+ if (explicit !== "") return explicit;
26
+ const own = exec?.agent?.session?.id;
27
+ if (typeof own === "string" && own !== "") return own;
28
+ throw new Error("no session id: pass sessionId, or call this from inside a session");
29
+ }
30
+
31
+ /** Shared `sessionId` parameter description. */
32
+ const SESSION_ID_PARAM = {
33
+ type: "string",
34
+ description: "Session id to inspect. Omit to use the session this call runs in (`session-<uuid>` and bare-uuid spellings are both accepted).",
35
+ };
36
+
37
+ /**
38
+ * Build both tool specs bound to one plugin instance.
39
+ *
40
+ * @param options - `{ ctx, settings }`.
41
+ * @returns tool specs consumed by the entry point.
42
+ */
43
+ export function createToolSpecs({ ctx, settings }) {
44
+ return [
45
+ {
46
+ name: "session_path",
47
+ description:
48
+ "Resolve a DSH session's on-disk log path and workspace directory. Use when the user asks to copy a session's path, to check where a session is stored, or to hand a session off to another session. Omit sessionId for the current session. Read-only: nothing is written.",
49
+ parameters: {
50
+ sessionId: SESSION_ID_PARAM,
51
+ format: {
52
+ type: "string",
53
+ description: 'Copy payload: "path" (bare log path) or "block" (paste-ready handoff block, default).',
54
+ },
55
+ },
56
+ outputSchema: {
57
+ type: "object",
58
+ additionalProperties: false,
59
+ properties: {
60
+ ok: { type: "boolean", required: true },
61
+ sessionId: { type: "string", required: true },
62
+ title: { type: "string", required: true },
63
+ cwd: { type: "string", required: true },
64
+ logPath: { type: "string", required: true },
65
+ dir: { type: "string", required: true },
66
+ exists: { type: "boolean", required: true },
67
+ text: { type: "string", required: true },
68
+ },
69
+ },
70
+ presentCall: (args) => ({ card: "generic", title: "session_path", kind: "execute", detail: args?.sessionId ?? "" }),
71
+ render: (_args, value) =>
72
+ value.exists
73
+ ? [
74
+ { type: "text", text: `会话 ${value.sessionId} 的日志:${value.logPath}` },
75
+ { type: "text", text: value.text },
76
+ ]
77
+ : [{ type: "text", text: `未找到会话 ${value.sessionId} 的日志文件(sessions 根:${value.dir === "" ? "?" : value.dir})。` }],
78
+ async execute(args, exec) {
79
+ const sessionId = targetSessionId(args, exec);
80
+ const format = args?.format === "path" ? "path" : settings.copyFormat;
81
+ const info = await resolveSessionInfo(ctx, settings, sessionId, {});
82
+ return {
83
+ ok: true,
84
+ sessionId: info.sessionId,
85
+ title: info.title,
86
+ cwd: info.cwd,
87
+ logPath: info.logPath,
88
+ dir: info.dir,
89
+ exists: info.exists,
90
+ text: composeCopyText(info, format),
91
+ };
92
+ },
93
+ },
94
+ {
95
+ name: "session_handoff",
96
+ description:
97
+ "Write a readable, decompressed copy of a DSH session (Markdown transcript by default) and return its absolute path. Use to continue another session's work: call this, then `read` the returned file. Omit sessionId for the current session.",
98
+ parameters: {
99
+ sessionId: SESSION_ID_PARAM,
100
+ format: {
101
+ type: "string",
102
+ description: 'Output file: "md" (human/agent-readable transcript, default) or "jsonl" (plain decompressed event log).',
103
+ },
104
+ maxChars: {
105
+ type: "integer",
106
+ description: "Character budget for the written file; the most recent part of the session is kept. Defaults to the plugin setting (200000).",
107
+ },
108
+ },
109
+ outputSchema: {
110
+ type: "object",
111
+ additionalProperties: false,
112
+ properties: {
113
+ ok: { type: "boolean", required: true },
114
+ sessionId: { type: "string", required: true },
115
+ title: { type: "string", required: true },
116
+ cwd: { type: "string", required: true },
117
+ logPath: { type: "string", required: true },
118
+ file: { type: "string", required: true },
119
+ format: { type: "string", required: true },
120
+ chars: { type: "integer", required: true },
121
+ kept: { type: "integer", required: true },
122
+ truncated: { type: "boolean", required: true },
123
+ },
124
+ },
125
+ presentCall: (args) => ({ card: "generic", title: "session_handoff", kind: "execute", detail: args?.sessionId ?? "" }),
126
+ render: (_args, value) => {
127
+ const lines = [
128
+ `会话记录已写出:${value.file}`,
129
+ `格式 ${value.format} · ${value.chars} 字符 · ${value.kept} 条${value.truncated ? "(仅最近部分,前文已截断)" : ""}`,
130
+ `工作目录:${value.cwd === "" ? "(未知)" : value.cwd}`,
131
+ `原始日志:${value.logPath}`,
132
+ `下一步:用 read 读取 ${value.file} 即可接续该会话。`,
133
+ ];
134
+ return [{ type: "text", text: lines.join("\n") }];
135
+ },
136
+ async execute(args, exec) {
137
+ const sessionId = targetSessionId(args, exec);
138
+ const info = await resolveSessionInfo(ctx, settings, sessionId, {});
139
+ if (!info.exists) {
140
+ throw new Error(`no committed log found for session "${info.sessionId}" under ${info.root}; check the session id`);
141
+ }
142
+ const format = args?.format === "jsonl" ? "jsonl" : args?.format === "md" ? "md" : settings.handoffFormat;
143
+ const result = await writeHandoff(ctx, settings, info, {
144
+ format,
145
+ maxChars: Number(args?.maxChars) > 0 ? Number(args.maxChars) : undefined,
146
+ });
147
+ return {
148
+ ok: true,
149
+ sessionId: info.sessionId,
150
+ title: info.title,
151
+ cwd: info.cwd,
152
+ logPath: info.logPath,
153
+ file: result.file,
154
+ format: result.format,
155
+ chars: result.chars,
156
+ kept: result.kept,
157
+ truncated: result.truncated,
158
+ };
159
+ },
160
+ },
161
+ ];
162
+ }
package/lib/trust.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The trust fence for this plugin's HTTP route.
3
+ *
4
+ * The Web GUI is served on loopback and may also be reached through a LAN bind,
5
+ * a reverse proxy or a tunnel, so the route accepts a request only when its
6
+ * `Host` is loopback or an explicitly trusted authority, not a cross-site fetch,
7
+ * and — when an `Origin` is present — same-host. This mirrors the fence the
8
+ * shipped Web routes use; a browser cannot forge these headers from another
9
+ * origin, which is what keeps the route from becoming an open session-path oracle.
10
+ *
11
+ * @module dsh-session-path/trust
12
+ */
13
+
14
+ /** Case-insensitive header lookup (Node lowercases, fakes may not). */
15
+ function headerOf(headers, name) {
16
+ const value = headers?.[name];
17
+ if (typeof value === "string") return value;
18
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0];
19
+ return undefined;
20
+ }
21
+
22
+ /**
23
+ * Hostname only, from a `Host` header value (IPv6 brackets preserved).
24
+ *
25
+ * @param host - the raw header value.
26
+ * @returns the hostname, or null when unparsable.
27
+ */
28
+ export function authorityHostname(host) {
29
+ try {
30
+ return new URL(`http://${host}`).hostname.toLowerCase();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Whether a hostname is a loopback spelling.
38
+ *
39
+ * @param hostname - lowercased hostname.
40
+ * @returns true for localhost / 127.0.0.0-8 / ::1.
41
+ */
42
+ export function isLoopbackHostname(hostname) {
43
+ if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") return true;
44
+ return /^127(?:\.\d{1,3}){3}$/.test(hostname);
45
+ }
46
+
47
+ /**
48
+ * Whether `Host` matches one of the runtime's trusted authorities.
49
+ *
50
+ * @param host - raw `Host` header value.
51
+ * @param trustedHosts - authorities from the web runtime (`host` or `host:port`).
52
+ * @returns true on an exact authority match.
53
+ */
54
+ export function isTrustedAuthority(host, trustedHosts) {
55
+ if (!Array.isArray(trustedHosts)) return false;
56
+ const wanted = host.toLowerCase();
57
+ return trustedHosts.some((entry) => typeof entry === "string" && entry.toLowerCase() === wanted);
58
+ }
59
+
60
+ /**
61
+ * The fence itself.
62
+ *
63
+ * @param request - the HTTP request.
64
+ * @param trustedHosts - authorities from the web runtime.
65
+ * @returns true when the request may read session paths.
66
+ */
67
+ export function isTrustedRequest(request, trustedHosts) {
68
+ const host = headerOf(request?.headers, "host");
69
+ if (host === undefined) return false;
70
+ const hostname = authorityHostname(host);
71
+ if (hostname === null) return false;
72
+ if (!isLoopbackHostname(hostname) && !isTrustedAuthority(host, trustedHosts)) return false;
73
+ if (headerOf(request?.headers, "sec-fetch-site") === "cross-site") return false;
74
+ const origin = headerOf(request?.headers, "origin");
75
+ if (origin === undefined) return true;
76
+ try {
77
+ return new URL(origin).hostname.toLowerCase() === hostname;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
package/lib/zstd.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Read DSH session logs.
3
+ *
4
+ * A committed log is either plain JSONL or a Zstandard container holding **one
5
+ * independent frame per append batch** — the host flushes each batch as its own
6
+ * checksummed frame. Node's `zstdDecompressSync` decodes a single frame and
7
+ * fails on the next frame's magic, so the container must be walked frame by
8
+ * frame. {@link scanZstdFrames} does that structurally (block payloads are
9
+ * skipped, never inflated), which keeps memory to one frame at a time and makes
10
+ * a partially written tail frame harmless.
11
+ *
12
+ * @module dsh-session-path/zstd
13
+ */
14
+
15
+ import { open } from "node:fs/promises";
16
+ import zlib from "node:zlib";
17
+
18
+ /** `ZSTD_MAGICNUMBER`, little-endian in the stream. */
19
+ const ZSTD_MAGIC = 0xfd2fb528;
20
+
21
+ /** How many compressed bytes are pulled from disk per read. */
22
+ const READ_CHUNK_BYTES = 1 << 20;
23
+
24
+ /**
25
+ * Packed增量 rows: the host appends text/reasoning/tool-call deltas as these
26
+ * types. They dominate a log's size and are meaningless to a reader, so parsed
27
+ * records drop them before they can be materialised.
28
+ */
29
+ const PACKED_TYPES = new Set(["text-chunks", "reasoning-chunks", "tool-call-chunks", "text-delta", "reasoning-delta"]);
30
+
31
+ /**
32
+ * Byte ranges of the complete Zstandard frames at the head of `buffer`.
33
+ *
34
+ * Parsing stops at the first byte that is not a frame start (an incomplete tail
35
+ * frame, a corrupt region, or a skippable frame this plugin does not emit), so
36
+ * a truncated log yields its intact prefix instead of throwing.
37
+ *
38
+ * @param buffer - raw container bytes.
39
+ * @returns `{ start, end }[]` in stream order.
40
+ */
41
+ export function scanZstdFrames(buffer) {
42
+ const frames = [];
43
+ let offset = 0;
44
+ while (offset < buffer.length) {
45
+ const start = offset;
46
+ if (buffer.length - offset < 4) return frames;
47
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return frames;
48
+ offset += 4;
49
+ if (offset === buffer.length) return frames;
50
+ const descriptor = buffer.readUInt8(offset);
51
+ offset += 1;
52
+ if ((descriptor & 24) !== 0) return frames; // reserved bits: structurally invalid
53
+ const contentSizeFlag = descriptor >>> 6;
54
+ const singleSegment = (descriptor & 32) !== 0;
55
+ const checksum = (descriptor & 4) !== 0;
56
+ const dictionaryFlag = descriptor & 3;
57
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
58
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
59
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
60
+ if (buffer.length - offset < remainingHeaderBytes) return frames;
61
+ offset += remainingHeaderBytes;
62
+ for (;;) {
63
+ if (buffer.length - offset < 3) return frames;
64
+ const blockHeader = buffer.readUIntLE(offset, 3);
65
+ offset += 3;
66
+ const lastBlock = (blockHeader & 1) !== 0;
67
+ const blockType = (blockHeader >>> 1) & 3;
68
+ const blockSize = blockHeader >>> 3;
69
+ if (blockType === 3) return frames; // reserved block type
70
+ // An RLE block stores one byte regardless of its decompressed size.
71
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
72
+ if (buffer.length - offset < payloadBytes) return frames;
73
+ offset += payloadBytes;
74
+ if (lastBlock) break;
75
+ }
76
+ if (checksum) {
77
+ if (buffer.length - offset < 4) return frames;
78
+ offset += 4;
79
+ }
80
+ frames.push({ start, end: offset });
81
+ }
82
+ return frames;
83
+ }
84
+
85
+ /**
86
+ * Parse one log line, dropping blanks, packed deltas and damaged lines.
87
+ *
88
+ * @param line - one JSONL line.
89
+ * @returns the record, or null when it carries no information.
90
+ */
91
+ export function parseRecordLine(line) {
92
+ if (line.length === 0) return null;
93
+ if (line.length > 8 * 1024 * 1024) return null; // giant packed row: skip before JSON.parse
94
+ try {
95
+ const record = JSON.parse(line);
96
+ if (record === null || typeof record !== "object") return null;
97
+ if (PACKED_TYPES.has(record.type)) return null;
98
+ return record;
99
+ } catch {
100
+ return null; // best-effort replay: one damaged line must not kill the file
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Turn a text slice into records, carrying the unfinished tail line forward.
106
+ *
107
+ * @param text - decoded chunk.
108
+ * @param pending - leftover from the previous chunk.
109
+ * @returns the new pending buffer.
110
+ */
111
+ function consumeText(text, pending, out) {
112
+ const lines = text.split("\n");
113
+ lines[0] = pending + lines[0];
114
+ const next = lines.pop() ?? "";
115
+ for (const line of lines) {
116
+ const record = parseRecordLine(line);
117
+ if (record !== null) out.push(record);
118
+ }
119
+ return next;
120
+ }
121
+
122
+ /**
123
+ * Walk one session log and yield its records.
124
+ *
125
+ * The file is read in chunks; complete frames found at the head of the working
126
+ * buffer are inflated and released immediately, and everything after the last
127
+ * complete frame stays in a tail buffer for the next chunk. `maxBytes` bounds
128
+ * the compressed bytes read, which is how callers keep a title probe cheap on a
129
+ * multi-hundred-megabyte log.
130
+ *
131
+ * @param file - `session.vN.jsonl.zstd` or `session.jsonl` path.
132
+ * @param options - `{ maxBytes?, onFrame? }`.
133
+ * @yields parsed records in log order.
134
+ */
135
+ export async function* readSessionRecords(file, options = {}) {
136
+ const maxBytes = Number(options.maxBytes) > 0 ? Number(options.maxBytes) : Infinity;
137
+ const onFrame = typeof options.onFrame === "function" ? options.onFrame : null;
138
+ const handle = await open(file, "r");
139
+ try {
140
+ const compressed = file.endsWith(".zstd");
141
+ let pending = "";
142
+ let tail = Buffer.alloc(0);
143
+ let read = 0;
144
+ for (;;) {
145
+ const chunk = await handle.read(Buffer.allocUnsafe(READ_CHUNK_BYTES), 0, READ_CHUNK_BYTES, null);
146
+ if (chunk.bytesRead === 0) break;
147
+ read += chunk.bytesRead;
148
+ const slice = chunk.buffer.subarray(0, chunk.bytesRead);
149
+ const data = tail.length > 0 ? Buffer.concat([tail, slice]) : Buffer.from(slice);
150
+ tail = Buffer.alloc(0);
151
+ if (!compressed) {
152
+ const out = [];
153
+ pending = consumeText(data.toString("utf8"), pending, out);
154
+ if (out.length > 0) yield* out;
155
+ } else {
156
+ const frames = scanZstdFrames(data);
157
+ for (const frame of frames) {
158
+ const plain = inflateFrame(data, frame);
159
+ if (plain === null) return;
160
+ onFrame?.(frame.end - frame.start);
161
+ const out = [];
162
+ pending = consumeText(plain, pending, out);
163
+ if (out.length > 0) yield* out;
164
+ }
165
+ const consumed = frames.length > 0 ? frames[frames.length - 1].end : 0;
166
+ tail = Buffer.from(data.subarray(consumed));
167
+ // A full chunk that still yields no frame means the container is
168
+ // damaged; stop rather than buffer the rest of the file forever.
169
+ if (frames.length === 0 && tail.length >= READ_CHUNK_BYTES * 2) return;
170
+ }
171
+ if (read >= maxBytes) break;
172
+ }
173
+ if (!compressed && pending.length > 0) {
174
+ const record = parseRecordLine(pending);
175
+ if (record !== null) yield record;
176
+ }
177
+ } finally {
178
+ await handle.close();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Inflate one scanned frame, or null when the bytes are damaged.
184
+ *
185
+ * @param data - the working buffer.
186
+ * @param frame - `{ start, end }` from {@link scanZstdFrames}.
187
+ * @returns decoded UTF-8 text.
188
+ */
189
+ function inflateFrame(data, frame) {
190
+ try {
191
+ return inflate(data.subarray(frame.start, frame.end));
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Decode one complete Zstandard frame (absent on exotic runtimes). */
198
+ function inflate(bytes) {
199
+ if (typeof zlib.zstdDecompressSync !== "function") throw new Error("zstd is unavailable on this runtime");
200
+ return zlib.zstdDecompressSync(bytes).toString("utf8");
201
+ }
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "dsh-session-path",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.2",
4
+ "description": "DeepSeek Harness plugin: copy a session's log path (and an agent-readable handoff) so another session can read it and continue the work.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "deepseek-harness",
11
+ "session",
12
+ "session-log",
13
+ "handoff",
14
+ "path",
15
+ "会话",
16
+ "接续"
17
+ ],
18
+ "main": "lib/index.js",
19
+ "exports": {
20
+ ".": {
21
+ "default": "./lib/index.js"
22
+ },
23
+ "./client": {
24
+ "default": "./lib/client.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/**/*.js",
30
+ "cordis.patch.yml",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/439436269-ctrl/dsh-session-path.git"
38
+ },
39
+ "homepage": "https://github.com/439436269-ctrl/dsh-session-path#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/439436269-ctrl/dsh-session-path/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "engines": {
48
+ "node": ">=22"
49
+ },
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": "^4.0.1",
52
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2"
54
+ },
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "inject": [
61
+ "@deepseek-ai/dsh-client-ui-slots",
62
+ "@deepseek-ai/dsh-client-ui-primitives"
63
+ ],
64
+ "platform": "web"
65
+ },
66
+ "compatibility": {
67
+ "dshReleases": {
68
+ "0.2.0-rc.2": "compatible"
69
+ }
70
+ }
71
+ },
72
+ "scripts": {
73
+ "test": "node test/selftest.mjs"
74
+ }
6
75
  }