pinokiod 6.0.19 → 6.0.21

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/server/index.js CHANGED
@@ -4269,6 +4269,7 @@ class Server {
4269
4269
 
4270
4270
 
4271
4271
  await this.kernel.init({ port: this.port})
4272
+ await Environment.init({}, this.kernel)
4272
4273
  this.kernel.server_port = this.port
4273
4274
  this.kernel.peer.start(this.kernel)
4274
4275
 
@@ -5731,6 +5732,94 @@ class Server {
5731
5732
  const invalidateTerminalSessionDiscoveryCache = () => {
5732
5733
  terminalSessionDiscoveryCache.expires = 0
5733
5734
  }
5735
+ let terminalSessionRegistrySyncPromise = null
5736
+ let terminalSessionRegistryBootScrubbed = false
5737
+ let terminalSessionRegistryBootScrubPromise = null
5738
+
5739
+ const getTerminalSessionRegistryPath = () => {
5740
+ if (this.kernel && typeof this.kernel.path === "function") {
5741
+ return this.kernel.path("cache", "terminals", "sessions.json")
5742
+ }
5743
+ return path.resolve(os.homedir(), "pinokio", "cache", "terminals", "sessions.json")
5744
+ }
5745
+ const coerceTerminalRegistryItems = (items) => {
5746
+ if (!Array.isArray(items)) {
5747
+ return []
5748
+ }
5749
+ return items.filter((entry) => entry && typeof entry === "object")
5750
+ }
5751
+ const terminalRegistryItemsEqual = (left, right) => {
5752
+ return JSON.stringify(coerceTerminalRegistryItems(left)) === JSON.stringify(coerceTerminalRegistryItems(right))
5753
+ }
5754
+ const readTerminalSessionRegistry = async () => {
5755
+ const registryPath = getTerminalSessionRegistryPath()
5756
+ try {
5757
+ const raw = await fs.promises.readFile(registryPath, "utf8")
5758
+ const parsed = JSON.parse(raw)
5759
+ if (!parsed || typeof parsed !== "object") {
5760
+ return { items: [], exists: false }
5761
+ }
5762
+ return {
5763
+ items: coerceTerminalRegistryItems(parsed.items),
5764
+ exists: true
5765
+ }
5766
+ } catch (error) {
5767
+ return { items: [], exists: false }
5768
+ }
5769
+ }
5770
+ const writeTerminalSessionRegistry = async (items) => {
5771
+ const registryPath = getTerminalSessionRegistryPath()
5772
+ const normalizedItems = coerceTerminalRegistryItems(items)
5773
+ const payload = {
5774
+ updated_at: new Date().toISOString(),
5775
+ items: normalizedItems
5776
+ }
5777
+ await fs.promises.mkdir(path.dirname(registryPath), { recursive: true })
5778
+ const tmpPath = `${registryPath}.tmp`
5779
+ await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8")
5780
+ await fs.promises.rename(tmpPath, registryPath)
5781
+ return payload
5782
+ }
5783
+ const scrubTerminalSessionRegistryOnlineStateAtBoot = async () => {
5784
+ if (terminalSessionRegistryBootScrubbed) {
5785
+ return
5786
+ }
5787
+ if (!terminalSessionRegistryBootScrubPromise) {
5788
+ terminalSessionRegistryBootScrubPromise = (async () => {
5789
+ const registry = await readTerminalSessionRegistry()
5790
+ if (!registry.exists || !Array.isArray(registry.items) || registry.items.length === 0) {
5791
+ terminalSessionRegistryBootScrubbed = true
5792
+ return
5793
+ }
5794
+ let changed = false
5795
+ const scrubbedItems = registry.items.map((entry) => {
5796
+ if (!entry || typeof entry !== "object") {
5797
+ return entry
5798
+ }
5799
+ const onlineValue = entry.online
5800
+ const isOnline = onlineValue === true
5801
+ || onlineValue === 1
5802
+ || onlineValue === "1"
5803
+ || onlineValue === "true"
5804
+ if (!isOnline) {
5805
+ return entry
5806
+ }
5807
+ changed = true
5808
+ return {
5809
+ ...entry,
5810
+ online: false
5811
+ }
5812
+ })
5813
+ if (changed) {
5814
+ await writeTerminalSessionRegistry(scrubbedItems)
5815
+ }
5816
+ terminalSessionRegistryBootScrubbed = true
5817
+ })().finally(() => {
5818
+ terminalSessionRegistryBootScrubPromise = null
5819
+ })
5820
+ }
5821
+ return terminalSessionRegistryBootScrubPromise
5822
+ }
5734
5823
 
5735
5824
  const getTerminalSkillRoots = () => {
5736
5825
  const roots = []
@@ -6054,6 +6143,45 @@ class Server {
6054
6143
  return `${lines.join("\n").trim()}\n`
6055
6144
  }
6056
6145
 
6146
+ const buildCodexSelectedSkillMarkdown = (mergedBody) => {
6147
+ const body = String(mergedBody || "").trim()
6148
+ const lines = [
6149
+ "---",
6150
+ "name: Pinokio Selected Skills",
6151
+ "description: Session-specific skill bundle generated from the skills selected in Pinokio.",
6152
+ "tags:",
6153
+ "- pinokio",
6154
+ "- session",
6155
+ "- selected-skills",
6156
+ "---",
6157
+ "",
6158
+ body
6159
+ ]
6160
+ return `${lines.join("\n").trim()}\n`
6161
+ }
6162
+
6163
+ const ensureCodexSelectedSkillFrontmatter = async (sessionCwd) => {
6164
+ if (typeof sessionCwd !== "string" || sessionCwd.trim().length === 0) {
6165
+ return
6166
+ }
6167
+ const codexSkillPath = path.resolve(sessionCwd, ".agents", "skills", "pinokio-selected", "SKILL.md")
6168
+ let existing = ""
6169
+ try {
6170
+ existing = await fs.promises.readFile(codexSkillPath, "utf8")
6171
+ } catch (error) {
6172
+ if (error && error.code === "ENOENT") {
6173
+ return
6174
+ }
6175
+ throw error
6176
+ }
6177
+ const normalized = String(existing || "").replace(/\r\n/g, "\n")
6178
+ if (normalized.startsWith("---\n")) {
6179
+ return
6180
+ }
6181
+ const wrapped = buildCodexSelectedSkillMarkdown(normalized)
6182
+ await fs.promises.writeFile(codexSkillPath, wrapped, "utf8")
6183
+ }
6184
+
6057
6185
  const materializeTerminalSkillContext = async (sessionCwd, providerKey, selectedSkills) => {
6058
6186
  if (!Array.isArray(selectedSkills) || selectedSkills.length === 0) {
6059
6187
  return {
@@ -6102,7 +6230,8 @@ class Server {
6102
6230
  const codexSkillDir = path.resolve(sessionCwd, ".agents", "skills", "pinokio-selected")
6103
6231
  await fs.promises.mkdir(codexSkillDir, { recursive: true })
6104
6232
  const codexSkillPath = path.resolve(codexSkillDir, "SKILL.md")
6105
- await fs.promises.writeFile(codexSkillPath, merged, "utf8")
6233
+ const codexSkillBody = buildCodexSelectedSkillMarkdown(merged)
6234
+ await fs.promises.writeFile(codexSkillPath, codexSkillBody, "utf8")
6106
6235
  const agentsPath = path.resolve(sessionCwd, "AGENTS.md")
6107
6236
  await fs.promises.writeFile(agentsPath, "# Pinokio session instructions\n\nUse the `pinokio-selected` skill from `.agents/skills/pinokio-selected/SKILL.md` for this workspace.\n", "utf8")
6108
6237
  } else if (providerKey === "claude") {
@@ -6324,7 +6453,10 @@ class Server {
6324
6453
  const msg = candidate.messages[i]
6325
6454
  if (msg && typeof msg === "object") {
6326
6455
  const role = typeof msg.role === "string" ? msg.role.toLowerCase() : ""
6327
- if (role === "user") {
6456
+ const type = typeof msg.type === "string" ? msg.type.toLowerCase() : ""
6457
+ const author = typeof msg.author === "string" ? msg.author.toLowerCase() : ""
6458
+ const isUserMessage = role === "user" || type === "user" || author === "user" || author === "human"
6459
+ if (isUserMessage) {
6328
6460
  const text = trimSummary(extractTextContent(msg), 160)
6329
6461
  if (text) {
6330
6462
  return text
@@ -6416,7 +6548,8 @@ class Server {
6416
6548
  return normalizeClaudeSummary(fallback)
6417
6549
  }
6418
6550
 
6419
- const buildTerminalSessions = async (forceDiscovery = false) => {
6551
+ const buildTerminalSessions = async (forceDiscovery = false, options = {}) => {
6552
+ const cacheOnly = Boolean(options && options.cacheOnly)
6420
6553
  const buildCodexResumeBaseCommand = (entry) => {
6421
6554
  const defaultCommand = entry && entry.command ? entry.command : "npx -y @openai/codex@latest"
6422
6555
  return {
@@ -6815,31 +6948,38 @@ class Server {
6815
6948
  }
6816
6949
  seenNodes.add(candidate)
6817
6950
  const localCodexHint = inheritedHint || recordHasCodexHint(candidate, 2, 50)
6818
- const sessionField = findSessionField(candidate)
6951
+ const candidateType = typeof candidate.type === "string" ? candidate.type.toLowerCase() : ""
6952
+ // OpenClaw session logs include many message/tool-call ids. Only treat top-level
6953
+ // session envelopes as resumable session sources.
6954
+ const isOpenClawSessionEnvelope = candidateType === "session" || candidateType === "session_meta"
6955
+ const sessionField = isOpenClawSessionEnvelope ? findSessionField(candidate) : null
6819
6956
  if (sessionField && isCodexFieldAllowed(sessionField, candidate, filePath, localCodexHint)) {
6820
6957
  const sessionId = sessionField.value
6821
6958
  if (!seenIds.has(sessionId)) {
6822
- seenIds.add(sessionId)
6823
- const summary = normalizeDiscoveredSessionSummary(buildSessionSummary(candidate) || buildSessionSummary(record))
6824
- const cwd = extractWorkingDirectory(candidate) || extractWorkingDirectory(record)
6825
- const timestamp = parseSessionTimestamp(
6826
- candidate.timestamp
6827
- || candidate.ts
6828
- || candidate.updated_at
6829
- || candidate.created_at
6830
- || record.timestamp
6831
- || record.ts
6832
- || record.updated_at
6833
- || record.created_at
6834
- )
6835
- results.push({
6836
- id: sessionId,
6837
- cwd,
6838
- summary,
6839
- timestamp,
6840
- source: filePath,
6841
- metadata: candidate
6842
- })
6959
+ const cwdValue = extractWorkingDirectory(candidate) || extractWorkingDirectory(record)
6960
+ const cwd = typeof cwdValue === "string" ? cwdValue.trim() : ""
6961
+ if (cwd) {
6962
+ seenIds.add(sessionId)
6963
+ const summary = normalizeDiscoveredSessionSummary(buildSessionSummary(candidate) || buildSessionSummary(record))
6964
+ const timestamp = parseSessionTimestamp(
6965
+ candidate.timestamp
6966
+ || candidate.ts
6967
+ || candidate.updated_at
6968
+ || candidate.created_at
6969
+ || record.timestamp
6970
+ || record.ts
6971
+ || record.updated_at
6972
+ || record.created_at
6973
+ )
6974
+ results.push({
6975
+ id: sessionId,
6976
+ cwd,
6977
+ summary,
6978
+ timestamp,
6979
+ source: filePath,
6980
+ metadata: candidate
6981
+ })
6982
+ }
6843
6983
  }
6844
6984
  }
6845
6985
  if (depth >= maxDepth) {
@@ -6950,6 +7090,7 @@ class Server {
6950
7090
  }
6951
7091
 
6952
7092
  const home = os.homedir()
7093
+ const configuredHome = this.kernel && this.kernel.homedir ? path.resolve(this.kernel.homedir) : null
6953
7094
  const openClawAgentSessionRoots = await buildOpenClawAgentSessionRoots(home)
6954
7095
  const codexDiscovery = await buildCodexDiscovery(home, openClawAgentSessionRoots)
6955
7096
  const codexDiscoveryRoots = codexDiscovery.roots
@@ -6975,10 +7116,78 @@ class Server {
6975
7116
  ...buildClaudeDiscoveryRoots(home),
6976
7117
  ...openClawAgentSessionRoots
6977
7118
  ])
6978
- const geminiCanonicalRoots = normalizeDiscoveryRoots([
6979
- path.join(home, ".gemini", "tmp"),
6980
- path.join(home, ".config", "gemini", "tmp")
7119
+ const geminiHomeRoots = normalizeDiscoveryRoots([
7120
+ home,
7121
+ configuredHome
6981
7122
  ])
7123
+ const geminiRootCandidates = []
7124
+ for (let i = 0; i < geminiHomeRoots.length; i++) {
7125
+ const rootHome = geminiHomeRoots[i]
7126
+ geminiRootCandidates.push(path.join(rootHome, ".gemini", "tmp"))
7127
+ geminiRootCandidates.push(path.join(rootHome, ".config", "gemini", "tmp"))
7128
+ }
7129
+ const geminiCanonicalRoots = normalizeDiscoveryRoots(geminiRootCandidates)
7130
+ const geminiProjectRootCache = new Map()
7131
+ const getGeminiCanonicalTokenFromFile = (filePath) => {
7132
+ if (typeof filePath !== "string" || filePath.trim().length === 0) {
7133
+ return null
7134
+ }
7135
+ const resolved = path.resolve(filePath)
7136
+ for (let i = 0; i < geminiCanonicalRoots.length; i++) {
7137
+ const root = geminiCanonicalRoots[i]
7138
+ if (!root) {
7139
+ continue
7140
+ }
7141
+ const resolvedRoot = path.resolve(root)
7142
+ if (!(resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}${path.sep}`))) {
7143
+ continue
7144
+ }
7145
+ const relative = path.relative(resolvedRoot, resolved)
7146
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
7147
+ continue
7148
+ }
7149
+ const token = normalizeSessionToken(relative.split(path.sep)[0])
7150
+ if (token) {
7151
+ return token
7152
+ }
7153
+ }
7154
+ return null
7155
+ }
7156
+ const resolveGeminiProjectRoot = (filePath) => {
7157
+ const token = getGeminiCanonicalTokenFromFile(filePath)
7158
+ if (!token) {
7159
+ return null
7160
+ }
7161
+ if (geminiProjectRootCache.has(token)) {
7162
+ const cached = geminiProjectRootCache.get(token)
7163
+ return cached || null
7164
+ }
7165
+ let projectRoot = null
7166
+ for (let i = 0; i < geminiCanonicalRoots.length; i++) {
7167
+ const root = geminiCanonicalRoots[i]
7168
+ if (!root) {
7169
+ continue
7170
+ }
7171
+ const markerPath = path.resolve(root, token, ".project_root")
7172
+ try {
7173
+ const marker = fs.readFileSync(markerPath, "utf8")
7174
+ const normalized = typeof marker === "string" ? marker.trim() : ""
7175
+ if (normalized.length > 0) {
7176
+ projectRoot = normalized
7177
+ break
7178
+ }
7179
+ } catch (error) {
7180
+ }
7181
+ }
7182
+ if (!projectRoot && this.kernel && typeof this.kernel.path === "function") {
7183
+ const inferredPath = path.resolve(this.kernel.path("terminals", "gemini", token))
7184
+ if (fs.existsSync(inferredPath)) {
7185
+ projectRoot = inferredPath
7186
+ }
7187
+ }
7188
+ geminiProjectRootCache.set(token, projectRoot || "")
7189
+ return projectRoot
7190
+ }
6982
7191
  const isGeminiCanonicalSessionFile = (filePath) => {
6983
7192
  if (typeof filePath !== "string" || filePath.length === 0) {
6984
7193
  return false
@@ -6991,6 +7200,18 @@ class Server {
6991
7200
  }
6992
7201
  return /[\\/]chats[\\/]session-[^\\/]+\.json$/i.test(path.resolve(filePath))
6993
7202
  }
7203
+ const isGeminiCanonicalLogFile = (filePath) => {
7204
+ if (typeof filePath !== "string" || filePath.length === 0) {
7205
+ return false
7206
+ }
7207
+ if (isOpenClawDiscoveryPath(filePath)) {
7208
+ return false
7209
+ }
7210
+ if (!isPathWithinRoots(filePath, geminiCanonicalRoots)) {
7211
+ return false
7212
+ }
7213
+ return /[\\/]logs\.json$/i.test(path.resolve(filePath))
7214
+ }
6994
7215
  const isGeminiForkCapableRecord = (candidate, filePath) => {
6995
7216
  if (!candidate || typeof candidate !== "object") {
6996
7217
  return false
@@ -7121,7 +7342,7 @@ class Server {
7121
7342
  if (isOpenClawDiscoveryPath(filePath)) {
7122
7343
  return true
7123
7344
  }
7124
- return isGeminiCanonicalSessionFile(filePath)
7345
+ return isGeminiCanonicalSessionFile(filePath) || isGeminiCanonicalLogFile(filePath)
7125
7346
  },
7126
7347
  extract: (record, filePath) => {
7127
7348
  if (!record || typeof record !== "object") {
@@ -7140,10 +7361,35 @@ class Server {
7140
7361
  }))
7141
7362
  }
7142
7363
  }
7364
+ const candidate = record.payload && typeof record.payload === "object" ? record.payload : record
7365
+ if (isGeminiCanonicalLogFile(filePath)) {
7366
+ const sessionField = findSessionField(candidate, geminiSessionIdKeys)
7367
+ const sessionId = sessionField ? sessionField.value : null
7368
+ if (!sessionId) {
7369
+ return null
7370
+ }
7371
+ const recordType = typeof candidate.type === "string" ? candidate.type.toLowerCase() : ""
7372
+ const isUserRecord = recordType === "user"
7373
+ const summary = normalizeDiscoveredSessionSummary(
7374
+ isUserRecord
7375
+ ? extractTextContent(candidate.message || candidate.content || candidate.prompt || candidate.text)
7376
+ : buildSessionSummary(candidate)
7377
+ )
7378
+ return {
7379
+ id: sessionId,
7380
+ cwd: extractWorkingDirectory(candidate) || resolveGeminiProjectRoot(filePath),
7381
+ summary,
7382
+ timestamp: parseSessionTimestamp(candidate.timestamp || candidate.ts || candidate.lastUpdated || candidate.startTime || record.timestamp || record.ts),
7383
+ source: filePath,
7384
+ source_kind: "gemini_log",
7385
+ resume_capable: false,
7386
+ resume_disabled_reason: "Gemini log-only session cannot be resumed directly.",
7387
+ fork_capable: false
7388
+ }
7389
+ }
7143
7390
  if (!isGeminiCanonicalSessionFile(filePath)) {
7144
7391
  return null
7145
7392
  }
7146
- const candidate = record.payload && typeof record.payload === "object" ? record.payload : record
7147
7393
  const sessionField = findSessionField(candidate, geminiSessionIdKeys)
7148
7394
  const sessionId = sessionField ? sessionField.value : null
7149
7395
  if (!sessionId) {
@@ -7151,13 +7397,15 @@ class Server {
7151
7397
  }
7152
7398
  const rootSessionId = normalizeSessionIdentifier(candidate.sessionId || candidate.session_id)
7153
7399
  const forkCapable = isGeminiForkCapableRecord(candidate, filePath) && rootSessionId === sessionId
7154
- const summary = buildSessionSummary(candidate)
7400
+ const summary = normalizeDiscoveredSessionSummary(buildSessionSummary(candidate))
7155
7401
  return {
7156
7402
  id: sessionId,
7157
- cwd: extractWorkingDirectory(candidate),
7403
+ cwd: extractWorkingDirectory(candidate) || resolveGeminiProjectRoot(filePath),
7158
7404
  summary,
7159
7405
  timestamp: parseSessionTimestamp(candidate.timestamp || candidate.ts || candidate.lastUpdated || candidate.startTime || record.timestamp || record.ts),
7160
7406
  source: filePath,
7407
+ source_kind: "gemini_session",
7408
+ resume_capable: true,
7161
7409
  fork_capable: forkCapable
7162
7410
  }
7163
7411
  }
@@ -7290,6 +7538,43 @@ class Server {
7290
7538
  }
7291
7539
  return Array.from(keySet.values())
7292
7540
  }
7541
+ const inferProviderFromCommand = (commandText) => {
7542
+ if (typeof commandText !== "string" || commandText.trim().length === 0) {
7543
+ return ""
7544
+ }
7545
+ for (let i = 0; i < providerKeys.length; i++) {
7546
+ const key = providerKeys[i]
7547
+ const terms = getProviderHintTerms(key)
7548
+ if (stringHasHintTerm(commandText, terms)) {
7549
+ return key
7550
+ }
7551
+ }
7552
+ return ""
7553
+ }
7554
+ const parseSessionTokensFromCommand = (commandText) => {
7555
+ if (typeof commandText !== "string" || commandText.trim().length === 0) {
7556
+ return []
7557
+ }
7558
+ const tokens = new Set()
7559
+ const patterns = [
7560
+ /--resume(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"'`]+))/gi,
7561
+ /\bresume\s+(?:"([^"]+)"|'([^']+)'|([^\s"'`]+))/gi,
7562
+ /\bfork\s+(?:"([^"]+)"|'([^']+)'|([^\s"'`]+))/gi,
7563
+ /session=(?:"([^"]+)"|'([^']+)'|([^\s"'`&]+))/gi
7564
+ ]
7565
+ for (let i = 0; i < patterns.length; i++) {
7566
+ const pattern = patterns[i]
7567
+ let match = null
7568
+ while ((match = pattern.exec(commandText)) !== null) {
7569
+ const rawToken = match[1] || match[2] || match[3] || ""
7570
+ const normalized = normalizeSessionToken(rawToken)
7571
+ if (normalized) {
7572
+ tokens.add(normalized)
7573
+ }
7574
+ }
7575
+ }
7576
+ return Array.from(tokens.values())
7577
+ }
7293
7578
  const normalizeCwdKey = (value) => {
7294
7579
  if (typeof value !== "string") {
7295
7580
  return ""
@@ -7305,7 +7590,10 @@ class Server {
7305
7590
  return normalized
7306
7591
  }
7307
7592
  const runningSessionKeys = new Set()
7593
+ const runningProviderCwdKeys = new Set()
7308
7594
  const runningForkProviderCwdKeys = new Set()
7595
+ const runningShellIdsByTerminalId = new Map()
7596
+ const runningShellIdByTerminalSession = new Map()
7309
7597
  if (this.kernel && this.kernel.api && this.kernel.api.running) {
7310
7598
  for (const runningId of Object.keys(this.kernel.api.running)) {
7311
7599
  const keys = parseRunningSessionKeys(runningId)
@@ -7329,9 +7617,27 @@ class Server {
7329
7617
  }
7330
7618
  if (this.kernel && this.kernel.shell && Array.isArray(this.kernel.shell.shells)) {
7331
7619
  for (const shellEntry of this.kernel.shell.shells) {
7620
+ const shellIdText = shellEntry && shellEntry.id ? String(shellEntry.id) : ""
7621
+ if (shellIdText) {
7622
+ const querySeparatorIndex = shellIdText.indexOf("?")
7623
+ if (querySeparatorIndex >= 0) {
7624
+ const normalizedQuery = shellIdText.slice(querySeparatorIndex + 1).replace(/&amp;/g, "&")
7625
+ const shellParams = new URLSearchParams(normalizedQuery)
7626
+ const shellTerminalId = normalizeSessionToken(shellParams.get("terminal_id"))
7627
+ if (shellTerminalId) {
7628
+ if (!runningShellIdsByTerminalId.has(shellTerminalId)) {
7629
+ runningShellIdsByTerminalId.set(shellTerminalId, [])
7630
+ }
7631
+ runningShellIdsByTerminalId.get(shellTerminalId).push(shellIdText)
7632
+ const shellSessionId = normalizeSessionToken(shellParams.get("session"))
7633
+ if (shellSessionId) {
7634
+ runningShellIdByTerminalSession.set(`${shellTerminalId}|${shellSessionId}`, shellIdText)
7635
+ }
7636
+ }
7637
+ }
7638
+ }
7332
7639
  const keys = parseRunningSessionKeys(shellEntry && shellEntry.id ? shellEntry.id : null)
7333
7640
  let providerKey = ""
7334
- let hasForkMarker = false
7335
7641
  for (let i = 0; i < keys.length; i++) {
7336
7642
  const key = keys[i]
7337
7643
  runningSessionKeys.add(key)
@@ -7342,17 +7648,29 @@ class Server {
7342
7648
  if (!providerKey) {
7343
7649
  providerKey = key.slice(0, separatorIndex)
7344
7650
  }
7345
- if (!hasForkMarker) {
7346
- const sessionToken = key.slice(separatorIndex + 1).toLowerCase()
7347
- if (sessionToken.includes("fork:")) {
7348
- hasForkMarker = true
7651
+ }
7652
+ if (!providerKey) {
7653
+ const inferredProvider = inferProviderFromCommand(shellEntry && shellEntry.cmd ? shellEntry.cmd : "")
7654
+ if (inferredProvider) {
7655
+ providerKey = inferredProvider
7656
+ const inferredTokens = parseSessionTokensFromCommand(shellEntry && shellEntry.cmd ? shellEntry.cmd : "")
7657
+ for (let i = 0; i < inferredTokens.length; i++) {
7658
+ const token = inferredTokens[i]
7659
+ const variants = buildSessionTokenVariants(token)
7660
+ for (let j = 0; j < variants.length; j++) {
7661
+ runningSessionKeys.add(`${providerKey}:${variants[j]}`)
7662
+ }
7349
7663
  }
7350
7664
  }
7351
7665
  }
7352
- if (hasForkMarker && providerKey) {
7666
+ if (providerKey) {
7353
7667
  const cwdKey = normalizeCwdKey(shellEntry && shellEntry.path ? shellEntry.path : "")
7354
7668
  if (cwdKey) {
7355
- runningForkProviderCwdKeys.add(`${providerKey}|${cwdKey}`)
7669
+ const contextKey = `${providerKey}|${cwdKey}`
7670
+ runningProviderCwdKeys.add(contextKey)
7671
+ if (/[?&]session=(?:fork(?:[:]|%3A)|[^&]*(?:[:]|%3A)fork(?:[:]|%3A))/i.test(shellIdText)) {
7672
+ runningForkProviderCwdKeys.add(contextKey)
7673
+ }
7356
7674
  }
7357
7675
  }
7358
7676
  }
@@ -7587,6 +7905,63 @@ class Server {
7587
7905
 
7588
7906
  const refreshDiscoveryEntries = async () => {
7589
7907
  const byProvider = new Map()
7908
+ const terminalMetadataCache = new Map()
7909
+ const resolveWorkspaceTerminalId = async (providerKey, workingDirectory) => {
7910
+ const normalizedProvider = typeof providerKey === "string" ? providerKey.trim().toLowerCase() : ""
7911
+ const normalizedCwd = typeof workingDirectory === "string" ? workingDirectory.trim() : ""
7912
+ if (!normalizedProvider || !normalizedCwd) {
7913
+ return ""
7914
+ }
7915
+ const resolvedCwd = path.resolve(normalizedCwd)
7916
+ const cacheKey = `${normalizedProvider}|${resolvedCwd}`
7917
+ if (terminalMetadataCache.has(cacheKey)) {
7918
+ return terminalMetadataCache.get(cacheKey)
7919
+ }
7920
+ let terminalId = ""
7921
+ const metadataPath = path.resolve(resolvedCwd, ".pinokio-terminal.json")
7922
+ let parsedMetadata = null
7923
+ try {
7924
+ const rawMetadata = await fs.promises.readFile(metadataPath, "utf8")
7925
+ parsedMetadata = JSON.parse(rawMetadata)
7926
+ if (parsedMetadata && typeof parsedMetadata === "object") {
7927
+ const metadataProvider = typeof parsedMetadata.provider === "string"
7928
+ ? parsedMetadata.provider.trim().toLowerCase()
7929
+ : ""
7930
+ const metadataTerminalId = typeof parsedMetadata.terminal_id === "string"
7931
+ ? parsedMetadata.terminal_id.trim()
7932
+ : ""
7933
+ if ((!metadataProvider || metadataProvider === normalizedProvider) && metadataTerminalId) {
7934
+ terminalId = metadataTerminalId
7935
+ }
7936
+ }
7937
+ } catch (error) {
7938
+ }
7939
+ if (!terminalId && this.kernel && typeof this.kernel.path === "function") {
7940
+ const providerRoot = path.resolve(this.kernel.path("terminals", normalizedProvider))
7941
+ if (resolvedCwd === providerRoot || resolvedCwd.startsWith(`${providerRoot}${path.sep}`)) {
7942
+ const relative = path.relative(providerRoot, resolvedCwd)
7943
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
7944
+ const workspaceId = relative.split(path.sep)[0]
7945
+ if (workspaceId) {
7946
+ terminalId = `${normalizedProvider}:${workspaceId}`
7947
+ const nextMetadata = (parsedMetadata && typeof parsedMetadata === "object")
7948
+ ? { ...parsedMetadata }
7949
+ : {}
7950
+ if (!nextMetadata.provider) {
7951
+ nextMetadata.provider = normalizedProvider
7952
+ }
7953
+ if (!nextMetadata.cwd) {
7954
+ nextMetadata.cwd = resolvedCwd
7955
+ }
7956
+ nextMetadata.terminal_id = terminalId
7957
+ await fs.promises.writeFile(metadataPath, JSON.stringify(nextMetadata, null, 2), "utf8").catch(() => {})
7958
+ }
7959
+ }
7960
+ }
7961
+ }
7962
+ terminalMetadataCache.set(cacheKey, terminalId)
7963
+ return terminalId
7964
+ }
7590
7965
  for (let i = 0; i < providers.length; i++) {
7591
7966
  const provider = providers[i]
7592
7967
  for (let j = 0; j < provider.roots.length; j++) {
@@ -7638,14 +8013,34 @@ class Server {
7638
8013
  if (!extractedEntry || !extractedEntry.id) {
7639
8014
  continue
7640
8015
  }
8016
+ const extractedTerminalId = await resolveWorkspaceTerminalId(provider.key, extractedEntry.cwd)
7641
8017
  const key = `${provider.key}:${extractedEntry.id}`
7642
8018
  const ts = parseSessionTimestamp(extractedEntry.timestamp)
7643
8019
  const existing = byProvider.get(key)
8020
+ const existingSourceKind = existing && typeof existing.source_kind === "string" ? existing.source_kind : ""
8021
+ const extractedSourceKind = typeof extractedEntry.source_kind === "string" ? extractedEntry.source_kind : ""
8022
+ const existingResumeCapable = existing && typeof existing.resume_capable === "boolean" ? existing.resume_capable : null
8023
+ const extractedResumeCapable = typeof extractedEntry.resume_capable === "boolean" ? extractedEntry.resume_capable : null
8024
+ const existingResumeDisabledReason = existing && typeof existing.resume_disabled_reason === "string"
8025
+ ? existing.resume_disabled_reason
8026
+ : ""
8027
+ const extractedResumeDisabledReason = typeof extractedEntry.resume_disabled_reason === "string"
8028
+ ? extractedEntry.resume_disabled_reason
8029
+ : ""
7644
8030
  const existingForkCapable = existing && typeof existing.fork_capable === "boolean" ? existing.fork_capable : null
7645
8031
  const extractedForkCapable = typeof extractedEntry.fork_capable === "boolean" ? extractedEntry.fork_capable : null
7646
8032
  const mergedForkCapable = provider.key === "gemini"
7647
8033
  ? Boolean(existingForkCapable || extractedForkCapable)
7648
8034
  : true
8035
+ let mergedResumeCapable = null
8036
+ if (existingResumeCapable === true || extractedResumeCapable === true) {
8037
+ mergedResumeCapable = true
8038
+ } else if (existingResumeCapable === false || extractedResumeCapable === false) {
8039
+ mergedResumeCapable = false
8040
+ }
8041
+ const mergedResumeDisabledReason = mergedResumeCapable === false
8042
+ ? (extractedResumeDisabledReason || existingResumeDisabledReason || "")
8043
+ : ""
7649
8044
  const merged = {
7650
8045
  ...existing,
7651
8046
  id: extractedEntry.id,
@@ -7658,9 +8053,23 @@ class Server {
7658
8053
  summary: extractedEntry.summary || (existing && existing.summary),
7659
8054
  timestamp: existing ? Math.max(existing.timestamp || 0, ts) : ts,
7660
8055
  source: existing ? existing.source : file,
8056
+ source_kind: existingSourceKind || extractedSourceKind || null,
7661
8057
  metadata: existing ? (existing.metadata || extractedEntry.metadata || null) : (extractedEntry.metadata || null),
8058
+ terminal_id: extractedTerminalId || (existing && typeof existing.terminal_id === "string" ? existing.terminal_id : null),
7662
8059
  fork_capable: mergedForkCapable
7663
8060
  }
8061
+ if (mergedResumeCapable !== null) {
8062
+ merged.resume_capable = mergedResumeCapable
8063
+ }
8064
+ if (mergedResumeDisabledReason) {
8065
+ merged.resume_disabled_reason = mergedResumeDisabledReason
8066
+ } else if (mergedResumeCapable === true && Object.prototype.hasOwnProperty.call(merged, "resume_disabled_reason")) {
8067
+ delete merged.resume_disabled_reason
8068
+ }
8069
+ if (extractedSourceKind === "gemini_session") {
8070
+ merged.source = file
8071
+ merged.source_kind = extractedSourceKind
8072
+ }
7664
8073
  if (!merged.cwd && extractedEntry.cwd) {
7665
8074
  merged.cwd = extractedEntry.cwd
7666
8075
  }
@@ -7669,7 +8078,15 @@ class Server {
7669
8078
  }
7670
8079
  if (!existing || ts > existing.timestamp) {
7671
8080
  merged.timestamp = ts
7672
- merged.source = file
8081
+ const preserveGeminiSessionSource = provider.key === "gemini"
8082
+ && existingSourceKind === "gemini_session"
8083
+ && extractedSourceKind === "gemini_log"
8084
+ if (!preserveGeminiSessionSource) {
8085
+ merged.source = file
8086
+ if (extractedSourceKind) {
8087
+ merged.source_kind = extractedSourceKind
8088
+ }
8089
+ }
7673
8090
  if (extractedEntry.summary) {
7674
8091
  merged.summary = extractedEntry.summary
7675
8092
  }
@@ -7680,7 +8097,14 @@ class Server {
7680
8097
  }
7681
8098
  }
7682
8099
  }
7683
- const entries = Array.from(byProvider.values()).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
8100
+ const entries = Array.from(byProvider.values())
8101
+ .filter((entry) => {
8102
+ if (!entry || entry.provider !== "gemini") {
8103
+ return true
8104
+ }
8105
+ return entry.source_kind !== "gemini_log"
8106
+ })
8107
+ .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
7684
8108
  terminalSessionDiscoveryCache.entries = entries
7685
8109
  terminalSessionDiscoveryCache.expires = Date.now() + TERMINAL_SESSION_DISCOVERY_CACHE_TTL_MS
7686
8110
  return entries
@@ -7708,7 +8132,7 @@ class Server {
7708
8132
  const hasCachedEntries = Array.isArray(terminalSessionDiscoveryCache.entries)
7709
8133
  const cacheExpired = !hasCachedEntries || terminalSessionDiscoveryCache.expires <= now
7710
8134
  let discoveredEntries = hasCachedEntries ? terminalSessionDiscoveryCache.entries : []
7711
- if (forceDiscovery || !hasCachedEntries) {
8135
+ if (forceDiscovery) {
7712
8136
  let refreshed = null
7713
8137
  try {
7714
8138
  refreshed = await startDiscoveryRefresh(true)
@@ -7720,6 +8144,24 @@ class Server {
7720
8144
  } else {
7721
8145
  discoveredEntries = Array.isArray(terminalSessionDiscoveryCache.entries) ? terminalSessionDiscoveryCache.entries : []
7722
8146
  }
8147
+ } else if (!hasCachedEntries) {
8148
+ const pendingRefresh = startDiscoveryRefresh(false)
8149
+ if (pendingRefresh && typeof pendingRefresh.catch === "function") {
8150
+ pendingRefresh.catch(() => {})
8151
+ }
8152
+ if (!cacheOnly) {
8153
+ let refreshed = null
8154
+ try {
8155
+ refreshed = await startDiscoveryRefresh(true)
8156
+ } catch (error) {
8157
+ refreshed = null
8158
+ }
8159
+ if (Array.isArray(refreshed)) {
8160
+ discoveredEntries = refreshed
8161
+ } else {
8162
+ discoveredEntries = Array.isArray(terminalSessionDiscoveryCache.entries) ? terminalSessionDiscoveryCache.entries : []
8163
+ }
8164
+ }
7723
8165
  } else if (cacheExpired) {
7724
8166
  const pendingRefresh = startDiscoveryRefresh(false)
7725
8167
  if (pendingRefresh && typeof pendingRefresh.catch === "function") {
@@ -7740,7 +8182,33 @@ class Server {
7740
8182
  latestDiscoveredByProviderCwd.set(entryKey, entry)
7741
8183
  }
7742
8184
  }
7743
- const isDiscoveredEntryOnline = (entry) => {
8185
+ const getGeminiSourceSessionToken = (sourcePath) => {
8186
+ if (typeof sourcePath !== "string" || sourcePath.trim().length === 0) {
8187
+ return null
8188
+ }
8189
+ const resolved = path.resolve(sourcePath)
8190
+ for (let i = 0; i < geminiCanonicalRoots.length; i++) {
8191
+ const root = geminiCanonicalRoots[i]
8192
+ if (!root) {
8193
+ continue
8194
+ }
8195
+ const resolvedRoot = path.resolve(root)
8196
+ if (!(resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}${path.sep}`))) {
8197
+ continue
8198
+ }
8199
+ const relative = path.relative(resolvedRoot, resolved)
8200
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
8201
+ continue
8202
+ }
8203
+ const firstSegment = relative.split(path.sep)[0]
8204
+ const normalized = normalizeSessionToken(firstSegment)
8205
+ if (normalized) {
8206
+ return normalized
8207
+ }
8208
+ }
8209
+ return null
8210
+ }
8211
+ const isDiscoveredEntryDirectlyOnline = (entry) => {
7744
8212
  if (!entry) {
7745
8213
  return false
7746
8214
  }
@@ -7748,12 +8216,44 @@ class Server {
7748
8216
  return true
7749
8217
  }
7750
8218
  const providerKey = typeof entry.provider === "string" ? entry.provider.toLowerCase() : ""
8219
+ if (providerKey === "gemini") {
8220
+ const sourceSessionToken = getGeminiSourceSessionToken(entry.source)
8221
+ if (sourceSessionToken && isSessionOnline(providerKey, sourceSessionToken)) {
8222
+ return true
8223
+ }
8224
+ }
8225
+ return false
8226
+ }
8227
+ const directOnlineByProviderCwd = new Set()
8228
+ for (let i = 0; i < discoveredEntries.length; i++) {
8229
+ const entry = discoveredEntries[i]
8230
+ if (!isDiscoveredEntryDirectlyOnline(entry)) {
8231
+ continue
8232
+ }
8233
+ const providerKey = entry && typeof entry.provider === "string" ? entry.provider.toLowerCase() : ""
8234
+ const cwdKey = normalizeCwdKey(entry && entry.cwd ? entry.cwd : "")
8235
+ if (!providerKey || !cwdKey) {
8236
+ continue
8237
+ }
8238
+ directOnlineByProviderCwd.add(`${providerKey}|${cwdKey}`)
8239
+ }
8240
+ const isDiscoveredEntryOnline = (entry) => {
8241
+ if (!entry) {
8242
+ return false
8243
+ }
8244
+ if (isDiscoveredEntryDirectlyOnline(entry)) {
8245
+ return true
8246
+ }
8247
+ const providerKey = typeof entry.provider === "string" ? entry.provider.toLowerCase() : ""
7751
8248
  const cwdKey = normalizeCwdKey(entry.cwd || "")
7752
8249
  if (!providerKey || !cwdKey) {
7753
8250
  return false
7754
8251
  }
7755
8252
  const contextKey = `${providerKey}|${cwdKey}`
7756
- if (!runningForkProviderCwdKeys.has(contextKey)) {
8253
+ if (!runningProviderCwdKeys.has(contextKey)) {
8254
+ return false
8255
+ }
8256
+ if (directOnlineByProviderCwd.has(contextKey) && !runningForkProviderCwdKeys.has(contextKey)) {
7757
8257
  return false
7758
8258
  }
7759
8259
  const latest = latestDiscoveredByProviderCwd.get(contextKey)
@@ -7764,6 +8264,10 @@ class Server {
7764
8264
  }
7765
8265
 
7766
8266
  return Array.from(discoveredEntries || [])
8267
+ .filter((entry) => {
8268
+ const workingDirectory = entry && typeof entry.cwd === "string" ? entry.cwd.trim() : ""
8269
+ return workingDirectory.length > 0
8270
+ })
7767
8271
  .sort((a, b) => {
7768
8272
  const aOnline = isDiscoveredEntryOnline(a) ? 1 : 0
7769
8273
  const bOnline = isDiscoveredEntryOnline(b) ? 1 : 0
@@ -7775,7 +8279,10 @@ class Server {
7775
8279
  return tb - ta
7776
8280
  })
7777
8281
  .map((entry, index) => {
7778
- const workingDirectory = entry.cwd || this.kernel.path("api")
8282
+ const workingDirectory = typeof entry.cwd === "string" ? entry.cwd.trim() : ""
8283
+ const terminalId = typeof entry.terminal_id === "string" ? entry.terminal_id.trim() : ""
8284
+ const entryResumeCapable = typeof entry.resume_capable === "boolean" ? entry.resume_capable : null
8285
+ const resumeCapable = workingDirectory.length > 0 && entryResumeCapable !== false
7779
8286
  const codexResumeCommand = buildCodexResumeBaseCommand(entry)
7780
8287
  const resumeBaseCommand = codexResumeCommand.command
7781
8288
  const buildTemplatedSessionCommand = (template, sessionId) => {
@@ -7798,45 +8305,37 @@ class Server {
7798
8305
  const resumeCommand = buildTemplatedSessionCommand(resumeTemplate, entry.id)
7799
8306
  let forkCommand = buildTemplatedSessionCommand(forkTemplate, entry.id)
7800
8307
  let forkCapable = forkCommand !== resumeCommand
8308
+ let resumeDisabledReason = ""
7801
8309
  let forkDisabledReason = ""
8310
+ if (!resumeCapable) {
8311
+ const explicitResumeReason = typeof entry.resume_disabled_reason === "string"
8312
+ ? entry.resume_disabled_reason.trim()
8313
+ : ""
8314
+ if (explicitResumeReason) {
8315
+ resumeDisabledReason = explicitResumeReason
8316
+ } else if (workingDirectory.length === 0) {
8317
+ resumeDisabledReason = `${entry.providerLabel || "Session"} resume unavailable: missing working directory metadata.`
8318
+ } else {
8319
+ resumeDisabledReason = `${entry.providerLabel || "Session"} resume unavailable for this session.`
8320
+ }
8321
+ }
7802
8322
  if (entry.provider === "gemini" && typeof entry.source === "string" && entry.source.length > 0) {
7803
8323
  forkCapable = Boolean(entry.fork_capable)
7804
8324
  if (forkCapable) {
7805
- const cloneScript = [
7806
- 'const fs = require("fs")',
7807
- 'const path = require("path")',
7808
- 'const crypto = require("crypto")',
7809
- 'const sourcePath = process.argv[1]',
7810
- 'const expectedSessionId = process.argv[2]',
7811
- 'if (!sourcePath) process.exit(1)',
7812
- 'const raw = fs.readFileSync(sourcePath, "utf8")',
7813
- 'const payload = JSON.parse(raw)',
7814
- 'if (!payload || typeof payload !== "object") process.exit(1)',
7815
- 'const sourceSessionId = typeof payload.sessionId === "string" ? payload.sessionId : (typeof payload.session_id === "string" ? payload.session_id : "")',
7816
- 'if (!sourceSessionId) process.exit(1)',
7817
- 'if (expectedSessionId && sourceSessionId !== expectedSessionId) process.exit(1)',
7818
- 'if (!Array.isArray(payload.messages)) process.exit(1)',
7819
- 'const newSessionId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`',
7820
- 'const nowIso = new Date().toISOString()',
7821
- 'payload.sessionId = newSessionId',
7822
- 'if (typeof payload.session_id === "string") payload.session_id = newSessionId',
7823
- 'if (typeof payload.startTime === "string") payload.startTime = nowIso',
7824
- 'if (typeof payload.lastUpdated === "string") payload.lastUpdated = nowIso',
7825
- 'const stamp = nowIso.replace(/[:.]/g, "-")',
7826
- 'const filename = `session-${stamp}-${newSessionId.slice(0, 8)}.json`',
7827
- 'const targetPath = path.join(path.dirname(sourcePath), filename)',
7828
- 'fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2), "utf8")',
7829
- 'process.stdout.write(newSessionId)'
7830
- ].join(";")
7831
- const cloneAndResume = `FORK_SESSION_ID=$(node -e ${JSON.stringify(cloneScript)} ${JSON.stringify(entry.source)} ${JSON.stringify(entry.id)}) && ${resumeBaseCommand} --resume "$FORK_SESSION_ID"`
8325
+ const geminiForkResumeScript = path.resolve(__dirname, "scripts", "gemini_fork_and_resume.js")
8326
+ const cloneAndResume = `node ${JSON.stringify(geminiForkResumeScript)} ${JSON.stringify(entry.source)} ${JSON.stringify(entry.id)}`
7832
8327
  forkCommand = cloneAndResume
7833
8328
  } else {
7834
8329
  forkCommand = ""
7835
8330
  forkDisabledReason = "Gemini fork unavailable for this session format. Use /chat save and /chat resume to branch manually."
7836
8331
  }
7837
8332
  }
7838
- if (entry.provider === "codex" && forkCommand !== resumeCommand) {
7839
- forkCommand = `(${forkCommand}) || (${resumeCommand})`
8333
+ if (!resumeCapable) {
8334
+ forkCapable = false
8335
+ forkCommand = ""
8336
+ if (!forkDisabledReason) {
8337
+ forkDisabledReason = resumeDisabledReason
8338
+ }
7840
8339
  }
7841
8340
  if (!forkCapable && !forkDisabledReason) {
7842
8341
  forkDisabledReason = `${entry.providerLabel || "Session"} fork unavailable for this session.`
@@ -7849,6 +8348,9 @@ class Server {
7849
8348
  params.set("path", workingDirectory)
7850
8349
  params.set("cwd", workingDirectory)
7851
8350
  params.set("session", sessionValue)
8351
+ if (terminalId) {
8352
+ params.set("terminal_id", terminalId)
8353
+ }
7852
8354
  params.set("message", command)
7853
8355
  params.set("input", "1")
7854
8356
  if (forkMode) {
@@ -7856,11 +8358,23 @@ class Server {
7856
8358
  }
7857
8359
  return `${route}?${params.toString()}`
7858
8360
  }
7859
- const resumeUrl = buildShellRoute(resumeCommand, entry.id, false)
7860
- const forkUrl = forkCapable && forkCommand
8361
+ let resumeUrl = resumeCapable ? buildShellRoute(resumeCommand, entry.id, false) : "#"
8362
+ const forkUrl = resumeCapable && forkCapable && forkCommand
7861
8363
  ? buildShellRoute(forkCommand, `fork:${entry.id}`, true)
7862
8364
  : ""
7863
8365
  const online = isDiscoveredEntryOnline(entry)
8366
+ const normalizedTerminalId = normalizeSessionToken(terminalId) || terminalId
8367
+ if (resumeCapable && normalizedTerminalId) {
8368
+ const normalizedEntrySessionId = normalizeSessionToken(entry.id)
8369
+ const exactShellId = normalizedEntrySessionId
8370
+ ? runningShellIdByTerminalSession.get(`${normalizedTerminalId}|${normalizedEntrySessionId}`)
8371
+ : null
8372
+ const terminalShellIds = runningShellIdsByTerminalId.get(normalizedTerminalId) || []
8373
+ const resolvedShellId = exactShellId || terminalShellIds[terminalShellIds.length - 1] || ""
8374
+ if (resolvedShellId) {
8375
+ resumeUrl = resolvedShellId.startsWith("/") ? resolvedShellId : `/${resolvedShellId}`
8376
+ }
8377
+ }
7864
8378
  return {
7865
8379
  name: title,
7866
8380
  description: `${entry.providerLabel} · ${entry.cwd || "cwd unavailable"}`,
@@ -7869,19 +8383,52 @@ class Server {
7869
8383
  index,
7870
8384
  url: resumeUrl,
7871
8385
  browser_url: resumeUrl,
8386
+ resume_capable: resumeCapable,
8387
+ resume_disabled_reason: resumeDisabledReason || null,
7872
8388
  fork_url: forkUrl,
7873
8389
  fork_capable: forkCapable,
7874
8390
  fork_disabled_reason: forkDisabledReason || null,
7875
8391
  filepath: entry.cwd || "",
7876
8392
  provider_label: entry.providerLabel || entry.provider || "Session",
7877
8393
  cwd: entry.cwd || "",
7878
- timestamp: entry.timestamp || null
8394
+ timestamp: entry.timestamp || null,
8395
+ terminal_id: terminalId || null
8396
+ }
8397
+ })
8398
+ }
8399
+ const syncTerminalSessionRegistry = async () => {
8400
+ if (!terminalSessionRegistrySyncPromise) {
8401
+ terminalSessionRegistrySyncPromise = (async () => {
8402
+ const discoveredItems = await buildTerminalSessions(true)
8403
+ const normalizedDiscoveredItems = coerceTerminalRegistryItems(discoveredItems)
8404
+ const existingRegistry = await readTerminalSessionRegistry()
8405
+ const hasChanged = !terminalRegistryItemsEqual(existingRegistry.items, normalizedDiscoveredItems)
8406
+ if (hasChanged || !existingRegistry.exists) {
8407
+ await writeTerminalSessionRegistry(normalizedDiscoveredItems)
8408
+ }
8409
+ return {
8410
+ items: hasChanged ? normalizedDiscoveredItems : existingRegistry.items,
8411
+ changed: hasChanged
7879
8412
  }
8413
+ })().finally(() => {
8414
+ terminalSessionRegistrySyncPromise = null
7880
8415
  })
8416
+ }
8417
+ return terminalSessionRegistrySyncPromise
7881
8418
  }
7882
8419
 
7883
8420
  if (req.query.mode === "terminals" && (req.query.fetch === "1" || req.query.format === "json")) {
7884
8421
  const includeSkills = req.query.skills === "1"
8422
+ const hasLimitParam = Object.prototype.hasOwnProperty.call(req.query, "limit")
8423
+ const parsedLimit = Number.parseInt(req.query.limit, 10)
8424
+ const pageLimit = hasLimitParam && Number.isFinite(parsedLimit)
8425
+ ? Math.min(Math.max(parsedLimit, 1), 500)
8426
+ : null
8427
+ const parsedCursor = Number.parseInt(req.query.cursor, 10)
8428
+ const pageCursor = pageLimit !== null && Number.isFinite(parsedCursor) && parsedCursor > 0
8429
+ ? parsedCursor
8430
+ : 0
8431
+ const searchQuery = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : ""
7885
8432
  let serializedSkills = null
7886
8433
  if (includeSkills) {
7887
8434
  const terminalSkills = await listTerminalSkills()
@@ -7908,15 +8455,60 @@ class Server {
7908
8455
  res.status(400).json(errorPayload)
7909
8456
  return
7910
8457
  }
7911
- const forceDiscovery = req.query.refresh === "1" || req.query.fresh === "1" || req.query.force === "1"
7912
- const terminalItems = await buildTerminalSessions(forceDiscovery)
8458
+ await scrubTerminalSessionRegistryOnlineStateAtBoot().catch(() => {})
8459
+ const syncRequested = req.query.sync === "1"
8460
+ || req.query.refresh === "1"
8461
+ || req.query.fresh === "1"
8462
+ || req.query.force === "1"
8463
+ let terminalItems = []
8464
+ if (syncRequested) {
8465
+ const syncResult = await syncTerminalSessionRegistry()
8466
+ terminalItems = Array.isArray(syncResult && syncResult.items) ? syncResult.items : []
8467
+ } else {
8468
+ const registry = await readTerminalSessionRegistry()
8469
+ terminalItems = Array.isArray(registry.items) ? registry.items : []
8470
+ if (terminalItems.length === 0) {
8471
+ const syncResult = await syncTerminalSessionRegistry()
8472
+ terminalItems = Array.isArray(syncResult && syncResult.items) ? syncResult.items : []
8473
+ }
8474
+ }
7913
8475
  for (let i = 0; i < terminalItems.length; i++) {
7914
8476
  terminalItems[i].index = i
7915
8477
  }
8478
+ let filteredItems = terminalItems
8479
+ if (searchQuery) {
8480
+ filteredItems = terminalItems.filter((item) => {
8481
+ const haystack = [
8482
+ item && item.name ? item.name : "",
8483
+ item && item.description ? item.description : "",
8484
+ item && item.provider_label ? item.provider_label : "",
8485
+ item && item.cwd ? item.cwd : "",
8486
+ item && item.uri ? item.uri : ""
8487
+ ].join(" ").toLowerCase()
8488
+ return haystack.includes(searchQuery)
8489
+ })
8490
+ }
8491
+ let responseItems = filteredItems
8492
+ let pagination = null
8493
+ if (pageLimit !== null) {
8494
+ const safeCursor = Math.max(0, Math.min(pageCursor, filteredItems.length))
8495
+ const sliceEnd = Math.min(filteredItems.length, safeCursor + pageLimit)
8496
+ responseItems = filteredItems.slice(safeCursor, sliceEnd)
8497
+ pagination = {
8498
+ cursor: safeCursor,
8499
+ limit: pageLimit,
8500
+ total: filteredItems.length,
8501
+ hasMore: sliceEnd < filteredItems.length,
8502
+ nextCursor: sliceEnd < filteredItems.length ? sliceEnd : null
8503
+ }
8504
+ }
7916
8505
  const responsePayload = {
7917
- items: terminalItems,
8506
+ items: responseItems,
7918
8507
  providers: getTerminalStarterProviders().map((provider) => ({ key: provider.key, label: provider.label }))
7919
8508
  }
8509
+ if (pagination) {
8510
+ responsePayload.pagination = pagination
8511
+ }
7920
8512
  if (includeSkills) {
7921
8513
  responsePayload.skills = Array.isArray(serializedSkills) ? serializedSkills : []
7922
8514
  }
@@ -7929,28 +8521,13 @@ class Server {
7929
8521
  res.redirect("/home?mode=settings")
7930
8522
  return
7931
8523
  }
7932
- const initialTerminalItems = await buildTerminalSessions()
7933
- let terminalItems = Array.isArray(initialTerminalItems) ? initialTerminalItems : []
7934
- // Avoid serving a stale empty-first paint: if cached result is empty,
7935
- // force one fresh discovery pass before rendering.
7936
- if (terminalItems.length === 0) {
7937
- const freshTerminalItems = await buildTerminalSessions(true)
7938
- if (Array.isArray(freshTerminalItems) && freshTerminalItems.length > 0) {
7939
- terminalItems = freshTerminalItems
7940
- }
7941
- }
7942
- let listItems = terminalItems
7943
- for (let i = 0; i < listItems.length; i++) {
7944
- listItems[i].index = i
7945
- }
7946
-
7947
8524
  res.render("terminals", {
7948
8525
  logo: this.logo,
7949
8526
  theme: this.theme,
7950
8527
  agent: req.agent,
7951
8528
  query: req.query,
7952
8529
  uri: "/home?mode=terminals",
7953
- items: listItems,
8530
+ items: [],
7954
8531
  providers: getTerminalStarterProviders().map((provider) => ({ key: provider.key, label: provider.label })),
7955
8532
  skills: [],
7956
8533
  ishome: false
@@ -8220,6 +8797,160 @@ class Server {
8220
8797
 
8221
8798
  this.app.get("/home", renderHomePage)
8222
8799
 
8800
+ const normalizePathForComparison = (value) => {
8801
+ if (typeof value !== "string" || value.trim().length === 0) {
8802
+ return ""
8803
+ }
8804
+ let resolved = path.resolve(value.trim())
8805
+ if (process.platform === "win32") {
8806
+ resolved = resolved.toLowerCase()
8807
+ }
8808
+ return resolved
8809
+ }
8810
+ const isPathWithin = (candidate, parent) => {
8811
+ const normalizedCandidate = normalizePathForComparison(candidate)
8812
+ const normalizedParent = normalizePathForComparison(parent)
8813
+ if (!normalizedCandidate || !normalizedParent) {
8814
+ return false
8815
+ }
8816
+ if (normalizedCandidate === normalizedParent) {
8817
+ return true
8818
+ }
8819
+ const withSep = normalizedParent.endsWith(path.sep) ? normalizedParent : `${normalizedParent}${path.sep}`
8820
+ return normalizedCandidate.startsWith(withSep)
8821
+ }
8822
+
8823
+ this.app.post("/terminals/deploy/local", ex(async (req, res) => {
8824
+ const body = req.body && typeof req.body === "object" ? req.body : {}
8825
+ const folderName = typeof body.folderName === "string" ? body.folderName.trim() : ""
8826
+ const sessionUri = typeof body.sessionUri === "string" ? body.sessionUri.trim().toLowerCase() : ""
8827
+ const sessionCwdHint = typeof body.sessionCwd === "string" ? body.sessionCwd.trim() : ""
8828
+
8829
+ if (!folderName) {
8830
+ res.status(400).json({
8831
+ ok: false,
8832
+ error: "folderName is required"
8833
+ })
8834
+ return
8835
+ }
8836
+ if (folderName === "." || folderName === ".." || /[\\/]/.test(folderName) || folderName.includes("\0")) {
8837
+ res.status(400).json({
8838
+ ok: false,
8839
+ error: "Invalid folder name"
8840
+ })
8841
+ return
8842
+ }
8843
+
8844
+ const registryPath = this.kernel.path("cache", "terminals", "sessions.json")
8845
+ let registryItems = []
8846
+ try {
8847
+ const rawRegistry = await fs.promises.readFile(registryPath, "utf8")
8848
+ const parsedRegistry = JSON.parse(rawRegistry)
8849
+ if (parsedRegistry && Array.isArray(parsedRegistry.items)) {
8850
+ registryItems = parsedRegistry.items
8851
+ }
8852
+ } catch (error) {
8853
+ }
8854
+
8855
+ let sourcePath = ""
8856
+ if (sessionUri) {
8857
+ for (let i = 0; i < registryItems.length; i++) {
8858
+ const entry = registryItems[i]
8859
+ const entryUri = entry && typeof entry.uri === "string" ? entry.uri.trim().toLowerCase() : ""
8860
+ const entryCwd = entry && typeof entry.cwd === "string" ? entry.cwd.trim() : ""
8861
+ if (entryUri && entryCwd && entryUri === sessionUri) {
8862
+ sourcePath = entryCwd
8863
+ break
8864
+ }
8865
+ }
8866
+ }
8867
+
8868
+ if (!sourcePath && sessionCwdHint) {
8869
+ const normalizedHint = normalizePathForComparison(sessionCwdHint)
8870
+ for (let i = 0; i < registryItems.length; i++) {
8871
+ const entry = registryItems[i]
8872
+ const entryCwd = entry && typeof entry.cwd === "string" ? entry.cwd.trim() : ""
8873
+ if (entryCwd && normalizePathForComparison(entryCwd) === normalizedHint) {
8874
+ sourcePath = entryCwd
8875
+ break
8876
+ }
8877
+ }
8878
+ if (!sourcePath) {
8879
+ const terminalsRoot = this.kernel.path("terminals")
8880
+ if (isPathWithin(normalizedHint, terminalsRoot)) {
8881
+ sourcePath = normalizedHint
8882
+ }
8883
+ }
8884
+ }
8885
+
8886
+ if (!sourcePath) {
8887
+ res.status(400).json({
8888
+ ok: false,
8889
+ error: "Current session folder not found."
8890
+ })
8891
+ return
8892
+ }
8893
+
8894
+ let sourceStats = null
8895
+ try {
8896
+ sourceStats = await fs.promises.stat(sourcePath)
8897
+ } catch (error) {
8898
+ }
8899
+ if (!sourceStats || !sourceStats.isDirectory()) {
8900
+ res.status(400).json({
8901
+ ok: false,
8902
+ error: "Current session folder not found."
8903
+ })
8904
+ return
8905
+ }
8906
+
8907
+ const apiRoot = this.kernel.path("api")
8908
+ await fs.promises.mkdir(apiRoot, { recursive: true })
8909
+ const targetPath = this.kernel.path("api", folderName)
8910
+ const normalizedSourcePath = normalizePathForComparison(sourcePath)
8911
+ const normalizedTargetPath = normalizePathForComparison(targetPath)
8912
+ if (!normalizedTargetPath || !isPathWithin(normalizedTargetPath, apiRoot)) {
8913
+ res.status(400).json({
8914
+ ok: false,
8915
+ error: "Invalid destination path."
8916
+ })
8917
+ return
8918
+ }
8919
+ if (isPathWithin(normalizedTargetPath, normalizedSourcePath)) {
8920
+ res.status(400).json({
8921
+ ok: false,
8922
+ error: "Invalid deployment target."
8923
+ })
8924
+ return
8925
+ }
8926
+
8927
+ const targetExists = await fs.promises.access(targetPath, fs.constants.F_OK).then(() => true).catch(() => false)
8928
+ if (targetExists) {
8929
+ res.json({
8930
+ ok: false,
8931
+ code: "exists",
8932
+ error: "Folder already exists"
8933
+ })
8934
+ return
8935
+ }
8936
+
8937
+ try {
8938
+ await fs.promises.cp(sourcePath, targetPath, { recursive: true })
8939
+ } catch (error) {
8940
+ res.status(500).json({
8941
+ ok: false,
8942
+ error: error && error.message ? error.message : "Failed to copy folder."
8943
+ })
8944
+ return
8945
+ }
8946
+
8947
+ res.json({
8948
+ ok: true,
8949
+ folder: folderName,
8950
+ path: targetPath
8951
+ })
8952
+ }))
8953
+
8223
8954
  this.app.post("/terminals/start", ex(async (req, res) => {
8224
8955
  const providers = getTerminalStarterProviders()
8225
8956
  const providerMap = new Map(providers.map((provider) => [provider.key, provider]))
@@ -8237,6 +8968,7 @@ class Server {
8237
8968
  const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
8238
8969
  const shortId = Math.random().toString(36).slice(2, 8)
8239
8970
  const folderName = `${timestamp}-${shortId}`
8971
+ const terminalId = `${provider.key}:${folderName}`
8240
8972
  const terminalsRoot = this.kernel.path("terminals")
8241
8973
  const providerRoot = path.resolve(terminalsRoot, provider.key)
8242
8974
  const sessionCwd = path.resolve(providerRoot, folderName)
@@ -8288,6 +9020,18 @@ class Server {
8288
9020
  }
8289
9021
 
8290
9022
  await fs.promises.mkdir(sessionCwd, { recursive: true })
9023
+ try {
9024
+ await this.kernel.exec({
9025
+ message: ["git init"],
9026
+ path: sessionCwd
9027
+ }, () => {})
9028
+ } catch (error) {
9029
+ await fs.promises.rm(sessionCwd, { recursive: true, force: true }).catch(() => {})
9030
+ res.status(500).json({
9031
+ error: error && error.message ? error.message : "Failed to initialize git repository for the new session."
9032
+ })
9033
+ return
9034
+ }
8291
9035
  const copiedUploads = []
8292
9036
  if (requestedUploadToken) {
8293
9037
  const uploadDir = path.resolve(this.kernel.path("tmp", "create", requestedUploadToken))
@@ -8331,6 +9075,7 @@ class Server {
8331
9075
  await fs.promises.writeFile(metadataPath, JSON.stringify({
8332
9076
  provider: provider.key,
8333
9077
  label: provider.label,
9078
+ terminal_id: terminalId,
8334
9079
  created_at: now.toISOString(),
8335
9080
  cwd: sessionCwd,
8336
9081
  command: provider.startCommand || provider.command,
@@ -8342,6 +9087,7 @@ class Server {
8342
9087
  const params = new URLSearchParams()
8343
9088
  params.set("path", sessionCwd)
8344
9089
  params.set("cwd", sessionCwd)
9090
+ params.set("terminal_id", terminalId)
8345
9091
  params.set("message", provider.startCommand || provider.command)
8346
9092
  params.set("input", "1")
8347
9093
  const route = `/shell/start-${provider.key}-${folderName}`
@@ -9131,12 +9877,37 @@ class Server {
9131
9877
 
9132
9878
  let baseShellId = "shell/" + decodeURIComponent(req.params.id)
9133
9879
  const sessionId = typeof req.query.session === "string" && req.query.session.length > 0 ? req.query.session : null
9880
+ const terminalId = typeof req.query.terminal_id === "string" && req.query.terminal_id.length > 0 ? req.query.terminal_id : null
9881
+ const isForkRequest = req.query.fork === "1"
9134
9882
  let id = baseShellId
9135
- if (sessionId) {
9136
- id = `${baseShellId}?session=${sessionId}`
9883
+ if (sessionId || terminalId) {
9884
+ const idParams = new URLSearchParams()
9885
+ if (sessionId) {
9886
+ idParams.set("session", sessionId)
9887
+ }
9888
+ if (terminalId) {
9889
+ idParams.set("terminal_id", terminalId)
9890
+ }
9891
+ id = `${baseShellId}?${idParams.toString()}`
9892
+ }
9893
+ let shell = this.kernel.shell.get(id)
9894
+ if (shell && terminalId && !isForkRequest) {
9895
+ req.query.message = ""
9137
9896
  }
9138
9897
  let target = req.query.target ? req.query.target : null
9139
- let cwd = this.kernel.path(this.kernel.api.filePath(decodeURIComponent(req.query.path)))
9898
+ const rawPathParam = typeof req.query.path === "string" && req.query.path.length > 0
9899
+ ? decodeURIComponent(req.query.path)
9900
+ : ""
9901
+ const rawCwdParam = typeof req.query.cwd === "string" && req.query.cwd.length > 0
9902
+ ? decodeURIComponent(req.query.cwd)
9903
+ : ""
9904
+ const shellPath = shell && typeof shell.path === "string" && shell.path.length > 0
9905
+ ? shell.path
9906
+ : ""
9907
+ const resolvedPath = rawPathParam || rawCwdParam || shellPath
9908
+ let cwd = resolvedPath
9909
+ ? this.kernel.path(this.kernel.api.filePath(resolvedPath))
9910
+ : this.kernel.homedir
9140
9911
  let message = req.query.message ? decodeURIComponent(req.query.message) : null
9141
9912
  //let message = req.query.message ? req.query.message : null
9142
9913
  let venv = req.query.venv ? decodeURIComponent(req.query.venv) : null
@@ -9173,8 +9944,7 @@ class Server {
9173
9944
  // pattern[key] = req.query[pattern_key]
9174
9945
  // }
9175
9946
  // }
9176
-
9177
- let shell = this.kernel.shell.get(id)
9947
+ await ensureCodexSelectedSkillFrontmatter(cwd).catch(() => {})
9178
9948
  res.render("shell", {
9179
9949
  target,
9180
9950
  filepath: cwd,