pinokiod 7.2.7 → 7.2.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.
@@ -0,0 +1,261 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+ const crypto = require("crypto")
4
+ const {
5
+ RESULT_RELATIVE_DIR,
6
+ POST_FILENAME,
7
+ DEFAULT_READY_FILENAME,
8
+ buildExcerpt,
9
+ describeMediaRefs,
10
+ extractTitle,
11
+ parseDraftMetadata
12
+ } = require("./parser")
13
+
14
+ const STATE_FILENAME = "drafts.json"
15
+ const MAX_PREVIEW_BYTES = 256 * 1024
16
+
17
+ function createHash(value) {
18
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
19
+ }
20
+
21
+ function dismissalKey(id, revision) {
22
+ const normalizedId = typeof id === "string" ? id.trim() : ""
23
+ const normalizedRevision = typeof revision === "string" ? revision.trim() : ""
24
+ return normalizedRevision ? `${normalizedId}:${normalizedRevision}` : normalizedId
25
+ }
26
+
27
+ function clonePlainObject(value) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
29
+ return null
30
+ }
31
+ try {
32
+ return JSON.parse(JSON.stringify(value))
33
+ } catch (_) {
34
+ return null
35
+ }
36
+ }
37
+
38
+ function normalizeRelativePath(value, fallback) {
39
+ const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
40
+ if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
41
+ return fallback
42
+ }
43
+ const normalized = path.posix.normalize(raw)
44
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
45
+ return fallback
46
+ }
47
+ return normalized
48
+ }
49
+
50
+ function normalizeDraftConfig(config = {}) {
51
+ const params = config && typeof config === "object" ? config : {}
52
+ return {
53
+ path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
54
+ content: normalizeRelativePath(params.content || params.post, POST_FILENAME),
55
+ ready: normalizeRelativePath(params.ready, DEFAULT_READY_FILENAME),
56
+ description: typeof params.description === "string" ? params.description.trim() : "",
57
+ publish: clonePlainObject(params.publish)
58
+ }
59
+ }
60
+
61
+ function createDraftService({ kernel, taskWorkspaceLinks } = {}) {
62
+ if (!kernel) {
63
+ throw new Error("kernel is required")
64
+ }
65
+
66
+ const statePath = () => path.resolve(kernel.path("tasks"), STATE_FILENAME)
67
+ const resultsByWorkspace = new Map()
68
+ const dismissedIds = new Set()
69
+ let started = false
70
+ let stateLoaded = false
71
+
72
+ async function ensureStateLoaded() {
73
+ if (stateLoaded) return
74
+ stateLoaded = true
75
+ try {
76
+ const raw = await fs.promises.readFile(statePath(), "utf8")
77
+ const parsed = JSON.parse(raw)
78
+ if (parsed && Array.isArray(parsed.dismissed)) {
79
+ parsed.dismissed.forEach((id) => {
80
+ if (typeof id === "string" && id.trim()) {
81
+ dismissedIds.add(id.trim())
82
+ }
83
+ })
84
+ }
85
+ } catch (_) {
86
+ }
87
+ }
88
+
89
+ async function saveState() {
90
+ await fs.promises.mkdir(path.dirname(statePath()), { recursive: true })
91
+ const payload = {
92
+ version: 1,
93
+ dismissed: Array.from(dismissedIds).slice(-500)
94
+ }
95
+ await fs.promises.writeFile(statePath(), JSON.stringify(payload, null, 2))
96
+ }
97
+
98
+ async function readMarkdownPreview(postPath) {
99
+ const handle = await fs.promises.open(postPath, "r")
100
+ try {
101
+ const buffer = Buffer.alloc(MAX_PREVIEW_BYTES)
102
+ const read = await handle.read(buffer, 0, MAX_PREVIEW_BYTES, 0)
103
+ return buffer.slice(0, read.bytesRead).toString("utf8")
104
+ } finally {
105
+ await handle.close().catch(() => {})
106
+ }
107
+ }
108
+
109
+ async function readDraftMetadata(metadataPath) {
110
+ if (path.extname(metadataPath).toLowerCase() !== ".json") {
111
+ return {}
112
+ }
113
+ const raw = await fs.promises.readFile(metadataPath, "utf8")
114
+ return parseDraftMetadata(raw)
115
+ }
116
+
117
+ async function inspectWorkspace({ taskId, ref, cwd, draft } = {}) {
118
+ await ensureStateLoaded()
119
+ if (typeof cwd !== "string" || !cwd.trim()) {
120
+ return null
121
+ }
122
+ const workspacePath = path.resolve(cwd.trim())
123
+ const draftConfig = normalizeDraftConfig(draft)
124
+ const resultDir = path.resolve(workspacePath, draftConfig.path)
125
+ const readyPath = path.resolve(resultDir, draftConfig.ready)
126
+ const postPath = path.resolve(resultDir, draftConfig.content)
127
+ const readyStats = await fs.promises.stat(readyPath).catch(() => null)
128
+ const postStats = await fs.promises.stat(postPath).catch(() => null)
129
+ if (!readyStats || !readyStats.isFile() || !postStats || !postStats.isFile()) {
130
+ resultsByWorkspace.delete(workspacePath)
131
+ return null
132
+ }
133
+ let metadata = {}
134
+ try {
135
+ metadata = await readDraftMetadata(readyPath)
136
+ } catch (_) {
137
+ resultsByWorkspace.delete(workspacePath)
138
+ return null
139
+ }
140
+
141
+ const markdown = await readMarkdownPreview(postPath)
142
+ const workspaceName = path.basename(workspacePath)
143
+ const media = await describeMediaRefs(markdown, resultDir)
144
+ const updatedAtMs = Math.max(readyStats.mtimeMs || 0, postStats.mtimeMs || 0)
145
+ const id = createHash(`${workspacePath}|${resultDir}|${postPath}|${readyPath}`)
146
+ const mediaRevision = media
147
+ .map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
148
+ .join("|")
149
+ const revision = createHash(`${postStats.size}|${postStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
150
+ const result = {
151
+ id,
152
+ revision,
153
+ taskId,
154
+ ref,
155
+ cwd: workspacePath,
156
+ workspaceName,
157
+ title: metadata.title || extractTitle(markdown, workspaceName),
158
+ markdown,
159
+ excerpt: buildExcerpt(markdown),
160
+ resultDir,
161
+ postPath,
162
+ contentPath: postPath,
163
+ readyPath,
164
+ metadataPath: readyPath,
165
+ metadata,
166
+ publish: draftConfig.publish,
167
+ description: draftConfig.description,
168
+ postBytes: postStats.size,
169
+ media: media.map((item, index) => ({
170
+ index,
171
+ ref: item.ref,
172
+ path: item.path,
173
+ bytes: item.bytes,
174
+ mtimeMs: item.mtimeMs,
175
+ exists: item.exists,
176
+ ext: path.extname(item.ref || "").toLowerCase()
177
+ })),
178
+ mediaCount: media.length,
179
+ missingMediaCount: media.filter((item) => !item.exists).length,
180
+ mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
181
+ updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
182
+ }
183
+ resultsByWorkspace.set(workspacePath, result)
184
+ return result
185
+ }
186
+
187
+ async function trackWorkspace({ taskId, ref, cwd } = {}) {
188
+ let resolvedCwd = cwd
189
+ if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
190
+ resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
191
+ }
192
+ if (!resolvedCwd) {
193
+ return null
194
+ }
195
+ return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
196
+ }
197
+
198
+ async function listPending(options = {}) {
199
+ await ensureStateLoaded()
200
+ const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
201
+ ? path.resolve(options.cwd.trim())
202
+ : ""
203
+ return Array.from(resultsByWorkspace.values())
204
+ .filter((result) => !dismissedIds.has(dismissalKey(result.id, result.revision)) && !dismissedIds.has(result.id))
205
+ .filter((result) => !filterCwd || result.cwd === filterCwd)
206
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
207
+ }
208
+
209
+ async function getPendingById(id) {
210
+ await ensureStateLoaded()
211
+ const normalizedId = typeof id === "string" ? id.trim() : ""
212
+ if (!normalizedId) {
213
+ return null
214
+ }
215
+ const result = Array.from(resultsByWorkspace.values()).find((item) => item.id === normalizedId) || null
216
+ if (!result || dismissedIds.has(dismissalKey(result.id, result.revision)) || dismissedIds.has(result.id)) {
217
+ return null
218
+ }
219
+ return result
220
+ }
221
+
222
+ async function dismiss(id, revision) {
223
+ await ensureStateLoaded()
224
+ const normalizedId = typeof id === "string" ? id.trim() : ""
225
+ if (!normalizedId) {
226
+ return false
227
+ }
228
+ dismissedIds.add(dismissalKey(normalizedId, revision))
229
+ await saveState()
230
+ return true
231
+ }
232
+
233
+ async function refreshLinkedWorkspaces() {
234
+ await ensureStateLoaded()
235
+ }
236
+
237
+ async function start() {
238
+ if (started) return
239
+ started = true
240
+ await ensureStateLoaded()
241
+ }
242
+
243
+ async function stop() {
244
+ }
245
+
246
+ return {
247
+ RESULT_RELATIVE_DIR,
248
+ dismiss,
249
+ getPendingById,
250
+ inspectWorkspace,
251
+ listPending,
252
+ refreshLinkedWorkspaces,
253
+ start,
254
+ stop,
255
+ trackWorkspace
256
+ }
257
+ }
258
+
259
+ module.exports = {
260
+ createDraftService
261
+ }
@@ -0,0 +1,76 @@
1
+ const path = require("path")
2
+
3
+ function isInside(candidate, parent) {
4
+ const relative = path.relative(parent, candidate)
5
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
6
+ }
7
+
8
+ class DraftWatcher {
9
+ constructor(options = {}) {
10
+ this.drafts = options.drafts
11
+ }
12
+
13
+ async ready(ctx, params = {}) {
14
+ if (!this.drafts || typeof this.drafts.inspectWorkspace !== "function") {
15
+ throw new Error("draft service is unavailable")
16
+ }
17
+
18
+ const draftDir = ctx.resolve(params.path || ".pinokio/draft")
19
+ const draftConfig = {
20
+ path: params.path || ".pinokio/draft",
21
+ content: params.content,
22
+ ready: params.ready,
23
+ description: params.description,
24
+ publish: params.publish
25
+ }
26
+ let timer = null
27
+ let disposed = false
28
+
29
+ const inspect = async () => {
30
+ if (disposed) return
31
+ await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig })
32
+ }
33
+
34
+ const scheduleInspect = () => {
35
+ if (disposed) return
36
+ if (timer) clearTimeout(timer)
37
+ timer = setTimeout(() => {
38
+ timer = null
39
+ inspect().catch((error) => {
40
+ console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error)
41
+ })
42
+ }, 250)
43
+ }
44
+
45
+ await inspect()
46
+ const stopPoll = ctx.poll(params.interval || 1500, inspect, {
47
+ immediate: false,
48
+ onError: (error) => {
49
+ console.warn("[drafts] poll failed", error && error.message ? error.message : error)
50
+ }
51
+ })
52
+ const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
53
+ if (!Array.isArray(events)) return
54
+ if (events.some((event) => event && event.path && isInside(path.resolve(event.path), draftDir))) {
55
+ scheduleInspect()
56
+ }
57
+ })
58
+
59
+ return async () => {
60
+ if (timer) {
61
+ clearTimeout(timer)
62
+ timer = null
63
+ }
64
+ await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig }).catch(() => {})
65
+ disposed = true
66
+ if (typeof stopPoll === "function") {
67
+ await stopPoll()
68
+ }
69
+ if (typeof unsubscribe === "function") {
70
+ await unsubscribe()
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ module.exports = DraftWatcher
@@ -0,0 +1,13 @@
1
+ const { createDraftFeature } = require("./drafts")
2
+
3
+ async function mountFeatures(options = {}) {
4
+ const drafts = createDraftFeature(options)
5
+ await drafts.start()
6
+ return {
7
+ drafts
8
+ }
9
+ }
10
+
11
+ module.exports = {
12
+ mountFeatures
13
+ }
package/server/index.js CHANGED
@@ -28,6 +28,8 @@ const system = require('systeminformation')
28
28
  const serveIndex = require('./serveIndex')
29
29
  const registerFileRoutes = require('./routes/files')
30
30
  const registerAppRoutes = require('./routes/apps')
31
+ const registerWorkspacesRoutes = require('./routes/workspaces')
32
+ const { mountFeatures } = require('./features')
31
33
  const Git = require("../kernel/git")
32
34
  const TerminalApi = require('../kernel/api/terminal')
33
35
 
@@ -78,6 +80,8 @@ const { createDesktopEventRouter } = require("./lib/desktop_event_router")
78
80
  const { createInjectRouter, resolveInjectList } = require("./lib/inject_router")
79
81
  const { createTaskPackageService } = require("./lib/task_packages")
80
82
  const { createTaskWorkspaceLinkService } = require("./lib/task_workspace_links")
83
+ const { createWorkspaceRuntimeService } = require("./lib/workspace_runtime")
84
+ const { createWorkspaceCatalogService } = require("./lib/workspace_catalog")
81
85
  const { createContentValidationService } = require("./lib/content_validation")
82
86
  const { buildSecureRouterDebugSnapshot, createSecureRouterDebugStore } = require("./lib/secure_router_debug")
83
87
  const AppRegistryService = require("./lib/app_registry")
@@ -2865,11 +2869,19 @@ class Server {
2865
2869
  const protectionPreference = protectionAppId && this.appPreferences && typeof this.appPreferences.getPreference === "function"
2866
2870
  ? await this.appPreferences.getPreference(protectionAppId)
2867
2871
  : null
2872
+ const draftWatchEnabled = this.kernel.watch && typeof this.kernel.watch.hasHandler === "function"
2873
+ ? this.kernel.watch.hasHandler(resolved, "draft")
2874
+ : false
2875
+ const draftWatchCwd = draftWatchEnabled
2876
+ ? (req.query.cwd || path.dirname(filepath))
2877
+ : ""
2868
2878
  const result = {
2869
2879
  portal: this.portal,
2870
2880
  projectName: (pathComponents.length > 0 ? pathComponents[0] : ''),
2871
2881
  protection_app_id: protectionAppId,
2872
2882
  protection_enabled: protectionPreference ? protectionPreference.protection_enabled !== false : false,
2883
+ draft_watch_enabled: draftWatchEnabled,
2884
+ draft_watch_cwd: draftWatchCwd,
2873
2885
  kill_message,
2874
2886
  callback,
2875
2887
  callback_target,
@@ -8403,6 +8415,32 @@ class Server {
8403
8415
  const taskWorkspaceLinks = createTaskWorkspaceLinkService({
8404
8416
  kernel: this.kernel
8405
8417
  })
8418
+ const workspaceRuntime = createWorkspaceRuntimeService({
8419
+ kernel: this.kernel
8420
+ })
8421
+ this.workspaceRuntime = workspaceRuntime
8422
+ const features = await mountFeatures({
8423
+ app: this.app,
8424
+ kernel: this.kernel,
8425
+ taskWorkspaceLinks,
8426
+ defaultRegistryUrl: DEFAULT_REGISTRY_URL
8427
+ })
8428
+ this.features = features
8429
+ const drafts = features.drafts.service
8430
+ this.drafts = drafts
8431
+ const workspaceCatalog = createWorkspaceCatalogService({
8432
+ kernel: this.kernel,
8433
+ workspaceRuntime,
8434
+ drafts
8435
+ })
8436
+ registerWorkspacesRoutes(this.app, {
8437
+ workspaceCatalog,
8438
+ composePeerAccessPayload: () => this.composePeerAccessPayload(),
8439
+ getTheme: () => this.theme,
8440
+ getPeers: () => this.getPeers(),
8441
+ getCurrentHost: () => this.kernel.peer.host,
8442
+ getPortal: () => this.portal
8443
+ })
8406
8444
  const TASK_INPUT_NAME_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
8407
8445
  const suggestTaskFolderName = async (rootDir, preferredName) => {
8408
8446
  const normalizedRoot = path.resolve(rootDir)
@@ -10073,7 +10111,8 @@ class Server {
10073
10111
  }
10074
10112
  }
10075
10113
 
10076
- const prompt = taskPackages.applyTemplateValues(task.template, filterFilledTaskInputValues(inputValues)).trim()
10114
+ const filledInputValues = filterFilledTaskInputValues(inputValues)
10115
+ const prompt = taskPackages.applyTemplateValues(task.template, filledInputValues).trim()
10077
10116
  if (!prompt) {
10078
10117
  await renderTaskLaunchPage(req, res, task, {
10079
10118
  selectedTool,
@@ -10194,6 +10233,9 @@ class Server {
10194
10233
  params.set("cwd", targetPath)
10195
10234
  params.set("chrome", "full")
10196
10235
  params.set("prompt", prompt)
10236
+ if (filledInputValues.url) {
10237
+ params.set("url", filledInputValues.url)
10238
+ }
10197
10239
  res.redirect(`${pluginHref}?${params.toString()}`)
10198
10240
  })
10199
10241
  this.app.post("/task/start", handleTaskStartRequest)
@@ -10500,7 +10542,8 @@ class Server {
10500
10542
  return
10501
10543
  }
10502
10544
 
10503
- const prompt = taskPackages.applyTemplateValues(task.template, filterFilledTaskInputValues(inputValues)).trim()
10545
+ const filledInputValues = filterFilledTaskInputValues(inputValues)
10546
+ const prompt = taskPackages.applyTemplateValues(task.template, filledInputValues).trim()
10504
10547
  if (!prompt) {
10505
10548
  res.status(400).json({
10506
10549
  ok: false,
@@ -10600,6 +10643,9 @@ class Server {
10600
10643
  params.set("chrome", "full")
10601
10644
  params.set("session", createUniversalLauncherSessionId())
10602
10645
  params.set("prompt", prompt)
10646
+ if (filledInputValues.url) {
10647
+ params.set("url", filledInputValues.url)
10648
+ }
10603
10649
 
10604
10650
  res.json({
10605
10651
  ok: true,
@@ -11851,11 +11897,14 @@ class Server {
11851
11897
  // but allow it when the request originates from the local machine
11852
11898
  if (payload.audience === 'device' && typeof payload.device_id === 'string' && payload.device_id) {
11853
11899
  try {
11854
- if (this.socket && typeof this.socket.isLocalDevice === 'function') {
11855
- payload.host = !!this.socket.isLocalDevice(payload.device_id)
11856
- } else {
11857
- payload.host = false
11858
- }
11900
+ const remoteAddress = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : req.ip
11901
+ const isLocalRequest = this.socket && typeof this.socket.isLocalAddress === 'function'
11902
+ ? this.socket.isLocalAddress(remoteAddress)
11903
+ : false
11904
+ const isLocalDevice = this.socket && typeof this.socket.isLocalDevice === 'function'
11905
+ ? this.socket.isLocalDevice(payload.device_id)
11906
+ : false
11907
+ payload.host = !!(isLocalRequest || isLocalDevice)
11859
11908
  } catch (_) {
11860
11909
  payload.host = false
11861
11910
  }
@@ -0,0 +1,151 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+
4
+ const SORT_MODES = new Set(["most_used", "last_opened", "az"])
5
+
6
+ function normalizeSortMode(sort) {
7
+ if (SORT_MODES.has(sort)) return sort
8
+ return "most_used"
9
+ }
10
+
11
+ function normalizePathKey(filepath) {
12
+ const resolved = path.resolve(filepath).replace(/[\\/]+$/, "")
13
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved
14
+ }
15
+
16
+ function toRoutePath(filepath) {
17
+ const resolved = path.resolve(filepath).replace(/\\/g, "/")
18
+ const encoded = resolved
19
+ .split("/")
20
+ .map((segment, index) => {
21
+ if (index === 0 && segment === "") return ""
22
+ return encodeURIComponent(segment)
23
+ })
24
+ .join("/")
25
+ return encoded.startsWith("/") ? `/d${encoded}` : `/d/${encoded}`
26
+ }
27
+
28
+ function latestTimestamp(values) {
29
+ return values.reduce((latest, value) => {
30
+ if (!value) return latest
31
+ const timestamp = typeof value === "number" ? value : new Date(value).getTime()
32
+ return Number.isFinite(timestamp) && timestamp > latest ? timestamp : latest
33
+ }, 0)
34
+ }
35
+
36
+ function sortWorkspaces(items, sort) {
37
+ const mode = normalizeSortMode(sort)
38
+ const sorted = [...items]
39
+ sorted.sort((a, b) => {
40
+ if (mode === "az") {
41
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
42
+ }
43
+ if (mode === "last_opened") {
44
+ const delta = (b.lastOpenedAtMs || b.modifiedAtMs || 0) - (a.lastOpenedAtMs || a.modifiedAtMs || 0)
45
+ if (delta !== 0) return delta
46
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
47
+ }
48
+ const usageDelta = (b.usageCount || 0) - (a.usageCount || 0)
49
+ if (usageDelta !== 0) return usageDelta
50
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
51
+ })
52
+ return sorted
53
+ }
54
+
55
+ function newestDraft(drafts) {
56
+ return [...drafts].sort((a, b) => {
57
+ return (new Date(b.updatedAt || 0).getTime() || 0) - (new Date(a.updatedAt || 0).getTime() || 0)
58
+ })[0] || null
59
+ }
60
+
61
+ function createWorkspaceCatalogService({ kernel, workspaceRuntime, drafts }) {
62
+ async function list(options = {}) {
63
+ const sort = normalizeSortMode(options.sort)
64
+ const root = path.resolve(kernel.path("workspaces"))
65
+ const entries = await fs.promises.readdir(root, { withFileTypes: true }).catch(() => [])
66
+ const runtime = workspaceRuntime.list()
67
+ const liveByPath = new Map()
68
+
69
+ for (const group of runtime.workspaces || []) {
70
+ if (group.root !== "workspaces") continue
71
+ liveByPath.set(normalizePathKey(group.cwd), group)
72
+ }
73
+
74
+ const draftByPath = new Map()
75
+ const pendingDrafts = drafts ? await drafts.listPending({}).catch(() => []) : []
76
+ for (const draft of pendingDrafts) {
77
+ if (!draft.cwd) continue
78
+ const key = normalizePathKey(draft.cwd)
79
+ const list = draftByPath.get(key) || []
80
+ list.push(draft)
81
+ draftByPath.set(key, list)
82
+ }
83
+
84
+ const folders = entries.filter((entry) => entry.isDirectory())
85
+ const items = []
86
+
87
+ for (const entry of folders) {
88
+ const cwd = path.join(root, entry.name)
89
+ const stats = await fs.promises.stat(cwd).catch(() => null)
90
+ const key = normalizePathKey(cwd)
91
+ const live = liveByPath.get(key)
92
+ const shells = live?.shells || []
93
+ const scripts = live?.scripts || []
94
+ const workspaceDrafts = draftByPath.get(key) || []
95
+ const draft = newestDraft(workspaceDrafts)
96
+ const modifiedAtMs = stats?.mtimeMs || 0
97
+ const lastOpenedAtMs = latestTimestamp([
98
+ modifiedAtMs,
99
+ ...shells.map((shell) => shell.start_time),
100
+ ...workspaceDrafts.map((item) => item.updatedAt),
101
+ ])
102
+ const primaryShell = shells.length === 1 ? shells[0] : null
103
+ const primaryScript = scripts.length === 1 ? scripts[0] : null
104
+ const usageCount = shells.length + scripts.length
105
+
106
+ items.push({
107
+ name: entry.name,
108
+ cwd,
109
+ relpath: entry.name,
110
+ modifiedAt: modifiedAtMs ? new Date(modifiedAtMs).toISOString() : null,
111
+ modifiedAtMs,
112
+ lastOpenedAt: lastOpenedAtMs ? new Date(lastOpenedAtMs).toISOString() : null,
113
+ lastOpenedAtMs,
114
+ usageCount,
115
+ running: shells.length > 0 || scripts.length > 0,
116
+ counts: {
117
+ shells: shells.length,
118
+ scripts: scripts.length,
119
+ drafts: workspaceDrafts.length,
120
+ },
121
+ shells,
122
+ scripts,
123
+ draft,
124
+ draftReady: Boolean(draft),
125
+ primaryUrl: primaryScript?.url || primaryShell?.url || null,
126
+ launchUrl: toRoutePath(cwd),
127
+ })
128
+ }
129
+
130
+ const running = sortWorkspaces(items.filter((item) => item.running), sort)
131
+ const offline = sortWorkspaces(items.filter((item) => !item.running), sort)
132
+
133
+ return {
134
+ root,
135
+ sort,
136
+ running,
137
+ offline,
138
+ items: [...running, ...offline],
139
+ counts: {
140
+ total: items.length,
141
+ running: running.length,
142
+ offline: offline.length,
143
+ drafts: items.filter((item) => item.draftReady).length,
144
+ },
145
+ }
146
+ }
147
+
148
+ return { list, normalizeSortMode }
149
+ }
150
+
151
+ module.exports = { createWorkspaceCatalogService, normalizeSortMode }