dsh-file-activity 0.5.7 → 0.5.9

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/lib/client.src.js CHANGED
@@ -1,29 +1,30 @@
1
1
  /**
2
2
  * dsh-file-activity — client half (browser).
3
3
  *
4
- * Extends dsh-better-sidebar with a "文件活动 / File Activity" tab:
5
- * - recent file access history (agent + sidebar operations),
6
- * - per-file create/modify/read counts flattened by folder, with multi-level
7
- * folders shown as dotted paths (a.b.c.d) and their files indented below,
8
- * - clicking any file opens a FLOATING preview that reuses the sidebar's
9
- * NATIVE viewer via `ctx.betterSidebar.matchFileViewer(path)` — its own
10
- * `component` is mounted (built-in markdown / code / image / pdf / html
11
- * renderers), so code gets syntax highlighting and markdown gets rendered
12
- * with no hand-rolled preview; clicking outside / Esc / × closes it,
13
- * - auto-opens once per session by default (toggleable in the sidebar
14
- * settings, enabled by default).
4
+ * A native right-Sidebar tab ("文件活动 / File Activity") built on the HOST's
5
+ * own extension points (issue #187 batch 2 — no third-party sidebar service):
6
+ * - the tab type registers into `ctx.sidebarRightTabs` (stage one) and its
7
+ * body / chip title into the keyed `sidebar.right.pane.tab` and
8
+ * `sidebar.right.pane.tab.title` seats (stage two);
9
+ * - clicking a file opens a FLOATING preview implemented by this plugin inside
10
+ * the `shell.overlay` list seat: recent access history (agent + plugin
11
+ * routes), per-file create/modify/read counts flattened by folder, and the
12
+ * preview window itself — clicking outside / Esc / × closes it;
13
+ * - a document-preview descriptor registers with `ctx.documentPreviews`
14
+ * (metadata only: the native document owner reads the bytes);
15
+ * - auto-opens once per session by default, with its toggle in the Web
16
+ * Settings → Plugins tab (`settings.plugins.tab`).
15
17
  *
16
18
  * Data source: the plugin host half (fs/observed for agent tools) + this
17
- * half's fetch interception for sidebar file operations (fs.read / fs.write /
18
- * /sidebar/file media opens), both persisted host-side; the tab polls
19
- * /file-activity/api/stats.
19
+ * half's fetch interception for the plugin's own file routes, both persisted
20
+ * host-side; the tab polls /file-activity/api/stats.
20
21
  *
21
- * Styling follows the dsh-better-sidebar design language: all colors ride the
22
- * DSH semantic tokens (--dsw-alias-*), typography rides the font roles
23
- * (--dsw-font-*), motion rides --ds-*. Flat surfaces (no box-shadow), hairline
24
- * borders, 28px circular icon controls with hover fills, and 8px-radius rows
25
- * with hover fills. The stylesheet is injected once per activation and torn
26
- * down with the fiber, so HMR/disable leaves no residue.
22
+ * Styling follows the DSH design language: all colors ride the DSH semantic
23
+ * tokens (--dsw-alias-*), typography rides the font roles (--dsw-font-*),
24
+ * motion rides --ds-*. Flat surfaces (no box-shadow), hairline borders, 28px
25
+ * circular icon controls with hover fills, and 8px-radius rows with hover
26
+ * fills. The stylesheet is injected once per activation and torn down with the
27
+ * fiber, so HMR/disable leaves no residue.
27
28
  *
28
29
  * BUILD NOTE: this file is the SOURCE TEMPLATE. scripts/build.mjs splices the
29
30
  * `lib/parts/*.part.js` pieces into the PART placeholder markers below (each
@@ -40,8 +41,11 @@ window.__ModuleLoader__.load({
40
41
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
41
42
  const { createElement, useEffect, useMemo, useState, useSyncExternalStore } = require('react')
42
43
 
43
- const TAB_ID = 'file-activity:recent'
44
+ const TAB_ID = 'dsh-file-activity'
45
+ const TAB_KIND = 'file-activity'
46
+ const PREVIEW_ID = 'dsh-file-activity-preview'
44
47
  const AUTO_OPEN_KEY = 'dsh-file-activity:auto-opened:'
48
+ const AUTO_OPEN_PREF_KEY = 'dsh-file-activity:autoOpen'
45
49
  const POLL_MS = 6000
46
50
 
47
51
  // ── parts (injected by scripts/build.mjs; keep this exact order — the
@@ -53,11 +57,14 @@ window.__ModuleLoader__.load({
53
57
  __PART_API__
54
58
  __PART_INTERCEPTOR__
55
59
  __PART_AUTO_OPEN__
60
+ __PART_DOCUMENT_PREVIEWS__
56
61
  __PART_ICONS__
57
62
  __PART_STYLES__
58
63
  __PART_ROWS__
59
64
  __PART_VIEW__
65
+ __PART_PREVIEW_DATA__
60
66
  __PART_PREVIEW__
67
+ __PART_SETTINGS__
61
68
  __PART_APPLY__
62
69
 
63
70
  return module.exports
package/lib/cwd.js CHANGED
@@ -4,11 +4,11 @@
4
4
  * 从 http.js 拆出(issue #45:http.js 的 JSON 工具已抽到 dsh-shared,
5
5
  * sessionCwdOf 为 file-activity 特有,保留本地)。
6
6
  */
7
-
8
- /** Session working directory, mirroring better-sidebar's resolution. */
7
+ /** Session working directory, mirroring the host's own session resolution. */
9
8
  export function sessionCwdOf(ctx, sessionId) {
10
- const session = ctx.sessions.get(sessionId)
11
- const headerCwd = session?.header?.cwd
12
- if (typeof headerCwd === 'string' && headerCwd !== '') return headerCwd
13
- return process.cwd()
9
+ const session = ctx.sessions.get(sessionId);
10
+ const headerCwd = session?.header?.cwd;
11
+ if (typeof headerCwd === 'string' && headerCwd !== '')
12
+ return headerCwd;
13
+ return process.cwd();
14
14
  }
package/lib/index.js CHANGED
@@ -1,116 +1,101 @@
1
1
  /**
2
2
  * dsh-file-activity — host half.
3
3
  *
4
- * Tracks file activity for dsh-better-sidebar:
4
+ * Tracks file activity for the DSH right sidebar:
5
5
  * - agent tool file operations arrive as `fs/observed` events (read / write /
6
6
  * edit / str_replace_editor / read_image ...), with the tool execution as
7
7
  * the actor (name + parsed arguments + owning agent).
8
8
  * - bash tool calls carry file-touching commands (rm/touch/mv/…): the
9
9
  * `tools/pre-execute` observer parses them and records the intents.
10
- * - sidebar operations (files opened / saved through the better-sidebar
10
+ * - sidebar operations (files opened / saved through the host's own
11
11
  * explorer & editor) are reported by our client half through the
12
12
  * `/file-activity/api/record` route.
13
13
  *
14
14
  * State (recent history + per-file counts) is kept per session and persisted
15
- * to $DSH_HOME/file-activity.json (atomic tmp+rename, debounced).
15
+ * to $DSH_HOME/file-activity.json as **JSON Lines**: 每条记录追加一行
16
+ * (落盘字节 ≈ 事件本体字节),累计行数达自适应阈值时原子 compact(issue #197:
17
+ * 旧实现每次防抖全量重写,写放大 3,665×)。入口三维上限(会话 / 每会话路径 /
18
+ * 全局路径)超限按 LRU 淘汰并告警 —— 内存与状态文件均有界。
16
19
  */
17
- import { isTrustedApiRequest } from 'dsh-shared'
18
- import { createApiHandler } from './api-route.js'
19
- import { createMediaHandler } from './media-route.js'
20
- import { createFsObserver } from './observer.js'
21
- import { createStore } from './store.js'
22
- import { parseBashFileOps } from './bash-parse.js'
23
- import { sessionCwdOf } from './cwd.js'
24
-
25
- export const name = 'dsh-file-activity'
26
-
27
- export const inject = ['webServer', 'sessions', 'webRuntime']
28
-
20
+ import { isTrustedApiRequest } from 'dsh-shared';
21
+ import { createApiHandler } from './api-route.js';
22
+ import { createMediaHandler } from './media-route.js';
23
+ import { createFsObserver } from './observer.js';
24
+ import { createStore } from './store.js';
25
+ import { parseBashFileOps } from './bash-parse.js';
26
+ import { sessionCwdOf } from './cwd.js';
27
+ export const name = 'dsh-file-activity';
28
+ export const inject = ['webServer', 'sessions', 'webRuntime'];
29
29
  /** 观察型监听:只读取 bash 命令隐含的文件操作并上报,不拦截(返回 next())。 */
30
30
  function observeBashIntents(exec, ctx, store) {
31
- const sessionId = bashSessionOf(exec)
32
- const command = bashCommandOf(exec)
33
- if (sessionId === '' || command === '') return
34
- const baseDir = bashBaseDirOf(exec, ctx, sessionId)
35
- for (const touched of parseBashFileOps(command, baseDir)) {
36
- store.record(sessionId, touched.path, touched.op, Date.now())
37
- }
31
+ const sessionId = bashSessionOf(exec);
32
+ const command = bashCommandOf(exec);
33
+ if (sessionId === '' || command === '')
34
+ return;
35
+ const baseDir = bashBaseDirOf(exec, ctx, sessionId);
36
+ for (const touched of parseBashFileOps(command, baseDir)) {
37
+ store.record(sessionId, touched.path, touched.op, Date.now());
38
+ }
38
39
  }
39
-
40
40
  /** 会话 id:仅 bash 工具、agent id 非空时返回。 */
41
41
  function bashSessionOf(exec) {
42
- if (exec?.name !== 'bash') return ''
43
- const id = exec.agent?.id
44
- return typeof id === 'string' && id !== '' ? id : ''
42
+ if (exec?.name !== 'bash')
43
+ return '';
44
+ const id = exec.agent?.id;
45
+ return typeof id === 'string' && id !== '' ? id : '';
45
46
  }
46
-
47
47
  /** 命令文本:仅非空字符串时返回。 */
48
48
  function bashCommandOf(exec) {
49
- const command = exec.arguments?.command
50
- return typeof command === 'string' && command !== '' ? command : ''
49
+ const command = exec.arguments?.command;
50
+ return typeof command === 'string' && command !== '' ? command : '';
51
51
  }
52
-
53
52
  /** 相对路径基准:workdir 参数优先,否则会话 cwd。 */
54
53
  function bashBaseDirOf(exec, ctx, sessionId) {
55
- const workdir = exec.arguments?.workdir
56
- return typeof workdir === 'string' && workdir !== '' ? workdir : sessionCwdOf(ctx, sessionId)
54
+ const workdir = exec.arguments?.workdir;
55
+ return typeof workdir === 'string' && workdir !== '' ? workdir : sessionCwdOf(ctx, sessionId);
57
56
  }
58
-
59
57
  export function apply(ctx) {
60
- const store = createStore(ctx)
61
-
62
- // ── agent-side file operations ──────────────────────────────────────────
63
- ctx.on('fs/observed', createFsObserver(store.record))
64
- ctx.on('tools/pre-execute', (exec, next) => {
65
- observeBashIntents(exec, ctx, store)
66
- return next()
67
- })
68
-
69
- // ── routes ──────────────────────────────────────────────────────────────
70
- const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts)
71
-
72
- ctx.effect(
73
- () =>
74
- ctx.webServer.register({
58
+ const store = createStore(ctx);
59
+ // ── agent-side file operations ──────────────────────────────────────────
60
+ ctx.on('fs/observed', createFsObserver(store.record));
61
+ ctx.on('tools/pre-execute', (exec, next) => {
62
+ observeBashIntents(exec, ctx, store);
63
+ return next();
64
+ });
65
+ // ── routes ──────────────────────────────────────────────────────────────
66
+ const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts);
67
+ ctx.effect(() => ctx.webServer.register({
75
68
  kind: 'prefix',
76
69
  path: '/file-activity/api',
77
70
  handler: createApiHandler({ ctx, store, fence }),
78
- }),
79
- 'dsh-file-activity: /file-activity/api routes',
80
- )
81
-
82
- // Media route for the floating preview (see media-route.js for the
83
- // authorization model: recorded paths only, same trust fence).
84
- ctx.effect(
85
- () =>
86
- ctx.webServer.register({
71
+ }), 'dsh-file-activity: /file-activity/api routes');
72
+ // Media route for the floating preview (see media-route.js for the
73
+ // authorization model: recorded paths only, same trust fence).
74
+ ctx.effect(() => ctx.webServer.register({
87
75
  kind: 'prefix',
88
76
  path: '/file-activity/file',
89
77
  handler: createMediaHandler({ ctx, store, fence }),
90
- }),
91
- 'dsh-file-activity: /file-activity/file media route',
92
- )
93
-
94
- // 插件状态查询(#155 聚合层)
95
- ctx.on('plugin:status-query', ({ plugin }) => {
96
- if (plugin !== 'dsh-file-activity') return undefined
97
- const sessions = store.state?.sessions ?? {}
98
- const totalFiles = Object.values(sessions).reduce((n, s) => n + Object.keys(s.known ?? {}).length, 0)
99
- const allRecent = Object.values(sessions).flatMap((s) => s.recent ?? [])
100
- allRecent.sort((a, b) => (b.time ?? 0) - (a.time ?? 0))
101
- const lastActions = allRecent.slice(0, 5).map((e) => ({ time: e.time, type: e.op ?? 'activity', detail: e.path }))
102
- return {
103
- ok: true,
104
- value: {
105
- plugin: 'dsh-file-activity',
106
- config: { keys: [] },
107
- running: true,
108
- stats: { sessions: Object.keys(sessions).length, totalFiles },
109
- lastActions,
110
- },
111
- }
112
- })
113
-
114
- // Tear down on unload: flush pending persistence.
115
- ctx.effect(() => store.dispose, 'dsh-file-activity: persistence teardown')
78
+ }), 'dsh-file-activity: /file-activity/file media route');
79
+ // 插件状态查询(#155 聚合层)
80
+ ctx.on('plugin:status-query', ({ plugin }) => {
81
+ if (plugin !== 'dsh-file-activity')
82
+ return undefined;
83
+ const sessions = store.state?.sessions ?? {};
84
+ const totalFiles = Object.values(sessions).reduce((n, s) => n + Object.keys(s.known ?? {}).length, 0);
85
+ const allRecent = Object.values(sessions).flatMap((s) => s.recent ?? []);
86
+ allRecent.sort((a, b) => (b.time ?? 0) - (a.time ?? 0));
87
+ const lastActions = allRecent.slice(0, 5).map((e) => ({ time: e.time, type: e.op ?? 'activity', detail: e.path }));
88
+ return {
89
+ ok: true,
90
+ value: {
91
+ plugin: 'dsh-file-activity',
92
+ config: { keys: [] },
93
+ running: true,
94
+ stats: { sessions: Object.keys(sessions).length, totalFiles },
95
+ lastActions,
96
+ },
97
+ };
98
+ });
99
+ // Tear down on unload: flush pending persistence.
100
+ ctx.effect(() => store.dispose, 'dsh-file-activity: persistence teardown');
116
101
  }
@@ -13,110 +13,136 @@
13
13
  * fence), the client falls back to this route and receives an fs.read-shaped
14
14
  * JSON payload ({ ok, value: { content } }) so the viewer mounts unchanged.
15
15
  */
16
- import { readFile, stat } from 'node:fs/promises'
17
- import { basename, isAbsolute, join } from 'node:path'
18
- import { writeJson } from 'dsh-shared'
19
- import { sessionCwdOf } from './cwd.js'
20
- import { isRecordedPath } from './state.js'
21
-
16
+ import { readFile, stat } from 'node:fs/promises';
17
+ import { basename, isAbsolute, join } from 'node:path';
18
+ import { writeJson } from 'dsh-shared';
19
+ import { sessionCwdOf } from './cwd.js';
20
+ import { isRecordedPath } from './state.js';
22
21
  /** Cap for the plugin's own media route (bytes): images / PDFs only. */
23
- const MEDIA_LIMIT = 64 * 1024 * 1024
24
-
22
+ const MEDIA_LIMIT = 64 * 1024 * 1024;
25
23
  /** Cap for `as=text` payloads (characters): text previews, not archives. */
26
- const TEXT_LIMIT = 2 * 1024 * 1024
27
-
24
+ const TEXT_LIMIT = 2 * 1024 * 1024;
28
25
  /** Content types served by /file-activity/file (mirrors the sidebar's set). */
29
26
  const MEDIA_TYPES = {
30
- '.png': 'image/png',
31
- '.jpg': 'image/jpeg',
32
- '.jpeg': 'image/jpeg',
33
- '.gif': 'image/gif',
34
- '.webp': 'image/webp',
35
- '.svg': 'image/svg+xml',
36
- '.bmp': 'image/bmp',
37
- '.ico': 'image/x-icon',
38
- '.avif': 'image/avif',
39
- '.pdf': 'application/pdf',
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ '.gif': 'image/gif',
31
+ '.webp': 'image/webp',
32
+ '.svg': 'image/svg+xml',
33
+ '.bmp': 'image/bmp',
34
+ '.ico': 'image/x-icon',
35
+ '.avif': 'image/avif',
36
+ '.pdf': 'application/pdf',
37
+ // HTML documents (issue #266 C): rendered in a sandboxed iframe by the
38
+ // floating preview, so they must be served as documents, not as text.
39
+ '.html': 'text/html',
40
+ '.htm': 'text/html',
41
+ '.xhtml': 'application/xhtml+xml',
42
+ };
43
+ /** HTML suffixes: always served through the sandboxing CSP below. */
44
+ const HTML_TYPES = new Set(['.html', '.htm', '.xhtml']);
45
+ /**
46
+ * Content-Security-Policy for a previewed HTML document (issue #266 C).
47
+ *
48
+ * `sandbox` with NO `allow-same-origin` forces an opaque origin: the document
49
+ * cannot read cookies, storage, or same-origin APIs, and it cannot script the
50
+ * embedding page. Scripts/forms stay enabled so interactive documents render.
51
+ * The header (not only the iframe's own `sandbox` attribute) is what keeps a
52
+ * DIRECT visit to this URL sandboxed too — the preview iframe is not the only
53
+ * way to reach it.
54
+ */
55
+ const HTML_SANDBOX_CSP = 'sandbox allow-scripts allow-forms';
56
+ /** 小写后缀(含点号;无后缀返回空串)。 */
57
+ function suffixOf(path) {
58
+ const dot = path.lastIndexOf('.');
59
+ return dot === -1 ? '' : path.slice(dot).toLowerCase();
40
60
  }
41
-
42
61
  function mediaTypeForPath(path) {
43
- const dot = path.lastIndexOf('.')
44
- const ext = dot === -1 ? '' : path.slice(dot).toLowerCase()
45
- return MEDIA_TYPES[ext] ?? 'application/octet-stream'
62
+ return MEDIA_TYPES[suffixOf(path)] ?? 'application/octet-stream';
46
63
  }
47
-
48
64
  /** Error carrying an HTTP status, for the media route's catch-all. */
49
65
  function mediaError(status, message) {
50
- const error = new Error(message)
51
- error.status = status
52
- return error
66
+ const error = new Error(message);
67
+ error.status = status;
68
+ return error;
53
69
  }
54
-
55
- export function createMediaHandler({ ctx, store, fence }) {
56
- return async (request, response) => {
57
- if (!fence(request)) {
58
- writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
59
- return
60
- }
61
- if (request.method !== 'GET') {
62
- writeJson(response, 405, { ok: false, error: { message: 'method not allowed' } })
63
- return
64
- }
65
- try {
66
- await serveRecordedFile(request, response, ctx, store)
67
- } catch (error) {
68
- const status = typeof error?.status === 'number' ? error.status : 400
69
- writeJson(response, status, {
70
- ok: false,
71
- error: { message: error instanceof Error ? error.message : String(error) },
72
- })
73
- }
74
- }
70
+ export function createMediaHandler({ ctx, store, fence, }) {
71
+ return async (request, response) => {
72
+ if (!fence(request)) {
73
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
74
+ return;
75
+ }
76
+ if (request.method !== 'GET') {
77
+ writeJson(response, 405, { ok: false, error: { message: 'method not allowed' } });
78
+ return;
79
+ }
80
+ try {
81
+ await serveRecordedFile(request, response, ctx, store);
82
+ }
83
+ catch (error) {
84
+ const status = typeof error?.status === 'number' ? error.status : 400;
85
+ writeJson(response, status, {
86
+ ok: false,
87
+ error: { message: error instanceof Error ? error.message : String(error) },
88
+ });
89
+ }
90
+ };
75
91
  }
76
-
77
92
  /**
78
93
  * Resolve a recorded path (authorized per session) and serve it: bytes
79
94
  * (images / PDFs, `download=1` supported) or, with `as=text`, an
80
95
  * fs.read-shaped JSON payload for the floating preview's text fallback.
81
96
  */
82
97
  async function serveRecordedFile(request, response, ctx, store) {
83
- const url = new URL(request.url ?? '/', 'http://dsh.internal')
84
- const sessionId = url.searchParams.get('sessionId')
85
- const raw = url.searchParams.get('path')
86
- assertMediaParams(sessionId, raw)
87
- if (!isRecordedPath(store.state, sessionId, raw)) throw mediaError(403, "path is not in this session's file activity")
88
- const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw)
89
- if (url.searchParams.get('as') === 'text') {
90
- await serveText(response, abs)
91
- return
92
- }
93
- await serveMedia(response, abs, url)
98
+ const url = new URL(request.url ?? '/', 'http://dsh.internal');
99
+ const sessionId = url.searchParams.get('sessionId');
100
+ const raw = url.searchParams.get('path');
101
+ assertMediaParams(sessionId, raw);
102
+ if (!isRecordedPath(store.state, sessionId, raw))
103
+ throw mediaError(403, "path is not in this session's file activity");
104
+ const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw);
105
+ if (url.searchParams.get('as') === 'text') {
106
+ await serveText(response, abs);
107
+ return;
108
+ }
109
+ await serveMedia(response, abs, url);
94
110
  }
95
-
96
111
  /** Both query parameters are required for a media request. */
97
112
  function assertMediaParams(sessionId, raw) {
98
- if (sessionId === null || raw === null || raw === '') throw mediaError(400, 'sessionId and path are required')
113
+ if (sessionId === null || raw === null || raw === '')
114
+ throw mediaError(400, 'sessionId and path are required');
99
115
  }
100
-
101
116
  /** stat + read + respond with the file's bytes (bounded by MEDIA_LIMIT). */
102
117
  async function serveMedia(response, abs, url) {
103
- let info
104
- try {
105
- info = await stat(abs)
106
- } catch {
107
- throw mediaError(404, 'file not found')
108
- }
109
- if (!info.isFile()) throw mediaError(400, 'not a file')
110
- if (info.size > MEDIA_LIMIT) throw mediaError(413, 'file too large')
111
- const body = await readFile(abs)
112
- const headers = { 'content-type': mediaTypeForPath(abs), 'cache-control': 'no-cache' }
113
- if (url.searchParams.get('download') === '1') {
114
- headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(abs))}`
115
- }
116
- response.writeHead(200, headers)
117
- response.end(body)
118
+ let info;
119
+ try {
120
+ info = await stat(abs);
121
+ }
122
+ catch {
123
+ throw mediaError(404, 'file not found');
124
+ }
125
+ if (!info.isFile())
126
+ throw mediaError(400, 'not a file');
127
+ if (info.size > MEDIA_LIMIT)
128
+ throw mediaError(413, 'file too large');
129
+ const body = await readFile(abs);
130
+ const headers = {
131
+ 'content-type': mediaTypeForPath(abs),
132
+ 'cache-control': 'no-cache',
133
+ };
134
+ // Untrusted document: the sandboxing CSP travels with the bytes, so the
135
+ // protection does not depend on the caller embedding it in a sandboxed frame.
136
+ if (HTML_TYPES.has(suffixOf(abs))) {
137
+ headers['content-security-policy'] = HTML_SANDBOX_CSP;
138
+ headers['x-content-type-options'] = 'nosniff';
139
+ }
140
+ if (url.searchParams.get('download') === '1') {
141
+ headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(abs))}`;
142
+ }
143
+ response.writeHead(200, headers);
144
+ response.end(body);
118
145
  }
119
-
120
146
  /**
121
147
  * `as=text` mode: serve a recorded file's text content as an fs.read-shaped
122
148
  * JSON payload ({ ok, value: { content } }) so the floating preview's text
@@ -125,14 +151,17 @@ async function serveMedia(response, abs, url) {
125
151
  * recorded paths only.
126
152
  */
127
153
  async function serveText(response, abs) {
128
- let info
129
- try {
130
- info = await stat(abs)
131
- } catch {
132
- throw mediaError(404, 'file not found')
133
- }
134
- if (!info.isFile()) throw mediaError(400, 'not a file')
135
- if (info.size > TEXT_LIMIT) throw mediaError(413, 'file too large')
136
- const content = await readFile(abs, 'utf8')
137
- writeJson(response, 200, { ok: true, value: { content } })
154
+ let info;
155
+ try {
156
+ info = await stat(abs);
157
+ }
158
+ catch {
159
+ throw mediaError(404, 'file not found');
160
+ }
161
+ if (!info.isFile())
162
+ throw mediaError(400, 'not a file');
163
+ if (info.size > TEXT_LIMIT)
164
+ throw mediaError(413, 'file too large');
165
+ const content = await readFile(abs, 'utf8');
166
+ writeJson(response, 200, { ok: true, value: { content } });
138
167
  }
package/lib/observer.js CHANGED
@@ -4,38 +4,44 @@
4
4
  * (backend displayPath preferred, falling back to the file_path argument)
5
5
  * and records it through the store's record().
6
6
  */
7
- import { mapOp } from './state.js'
8
-
7
+ import { mapOp } from './state.js';
9
8
  export function createFsObserver(record) {
10
- return (target, observation, actor) => {
11
- // Only authoritative PRESENT observations mean a file was actually
12
- // touched; absent observations (e.g. a failed read of a missing file)
13
- // are noise.
14
- if (!isPresentObservation(observation)) return
15
- if (!isValidActor(actor)) return
16
- const sessionId = actor.agent?.id
17
- if (typeof sessionId !== 'string' || sessionId === '') return
18
- // Prefer the backend-resolved absolute path; fall back to the raw argument.
19
- const rawPath = resolveObservedPath(target, actor)
20
- if (rawPath === '') return
21
- record(sessionId, rawPath, mapOp(actor.name), Date.now())
22
- }
9
+ return (target, observation, actor) => {
10
+ // Only authoritative PRESENT observations mean a file was actually
11
+ // touched; absent observations (e.g. a failed read of a missing file)
12
+ // are noise.
13
+ if (!isPresentObservation(observation))
14
+ return;
15
+ if (!isValidActor(actor))
16
+ return;
17
+ const sessionId = actor.agent?.id;
18
+ if (typeof sessionId !== 'string' || sessionId === '')
19
+ return;
20
+ // Prefer the backend-resolved absolute path; fall back to the raw argument.
21
+ const rawPath = resolveObservedPath(target, actor);
22
+ if (rawPath === '')
23
+ return;
24
+ record(sessionId, rawPath, mapOp(actor.name), Date.now());
25
+ };
23
26
  }
24
-
25
27
  /** A PRESENT observation is the only authoritative "file was touched" signal. */
26
28
  function isPresentObservation(observation) {
27
- return observation !== undefined && observation !== null && observation.kind === 'present'
29
+ return observation !== undefined && observation !== null && observation.kind === 'present';
28
30
  }
29
-
30
31
  /** The actor must exist and carry a non-empty string tool name. */
31
32
  function isValidActor(actor) {
32
- return actor !== undefined && actor !== null && typeof actor.name === 'string' && actor.name !== ''
33
+ if (actor === undefined || actor === null)
34
+ return false;
35
+ const name = actor.name;
36
+ return typeof name === 'string' && name !== '';
33
37
  }
34
-
35
38
  /** Resolve the touched path: backend displayPath preferred, else file_path. */
36
39
  function resolveObservedPath(target, actor) {
37
- if (typeof target?.displayPath === 'string' && target.displayPath !== '') return target.displayPath
38
- const args = actor.arguments
39
- if (args !== null && typeof args === 'object' && typeof args.file_path === 'string') return args.file_path
40
- return ''
40
+ const displayPath = target?.displayPath;
41
+ if (typeof displayPath === 'string' && displayPath !== '')
42
+ return displayPath;
43
+ const args = actor.arguments;
44
+ if (args !== null && typeof args === 'object' && typeof args.file_path === 'string')
45
+ return args.file_path;
46
+ return '';
41
47
  }