pantheon-opencode 1.4.3 → 1.5.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/session-end-save.py +186 -0
  12. package/.pantheon/code-mode/session-end-save.sh +18 -0
  13. package/.pantheon/code-mode/test-allowed.sh +5 -0
  14. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  15. package/.pantheon/code-mode/test-env.sh +7 -0
  16. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  17. package/.pantheon/code-mode/test-timeout.sh +6 -0
  18. package/README.md +161 -1101
  19. package/README.pt-BR.md +222 -0
  20. package/bin/pantheon-init.mjs +30 -11
  21. package/opencode.json +1 -0
  22. package/package.json +14 -3
  23. package/plugin.json +15 -0
  24. package/scripts/_pantheon_paths.py +48 -7
  25. package/scripts/ci-validate-yaml.py +7 -2
  26. package/scripts/code_mode_server.py +33 -2
  27. package/scripts/cost.mjs +30 -14
  28. package/scripts/doctor.mjs +135 -127
  29. package/scripts/generate-preset-docs.mjs +127 -66
  30. package/scripts/generate-prompts.sh +2 -2
  31. package/scripts/install/cli-ui.mjs +8 -1
  32. package/scripts/install/config-migration.mjs +401 -0
  33. package/scripts/install/health-check.mjs +3 -3
  34. package/scripts/install/model-picker.mjs +72 -23
  35. package/scripts/install/opencode-version.mjs +47 -0
  36. package/scripts/install/opencode.mjs +261 -88
  37. package/scripts/install/plugin.mjs +112 -42
  38. package/scripts/install/shared.mjs +13 -158
  39. package/scripts/install-mcp.mjs +44 -232
  40. package/scripts/mcp_persistence_server.py +382 -16
  41. package/scripts/mcp_resources_server.py +144 -40
  42. package/scripts/memory_mcp_server.py +2 -2
  43. package/scripts/probe-context-rehydrate.mjs +240 -0
  44. package/scripts/probe-pantheon-cost.mjs +304 -0
  45. package/scripts/recover-zenodo-deposition.mjs +146 -0
  46. package/scripts/sync-tui.mjs +32 -7
  47. package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
  48. package/scripts/uninstall.mjs +298 -954
  49. package/scripts/validate-zenodo-release.mjs +154 -0
  50. package/scripts/zenodo-artifact.mjs +21 -0
  51. package/scripts/zenodo-release-state.mjs +53 -0
  52. package/src/agents/zeus.md +25 -14
  53. package/src/mcp/_pantheon_paths.py +48 -7
  54. package/src/mcp/code_mode_server.py +33 -2
  55. package/src/mcp/eval_store.py +1 -1
  56. package/src/mcp/mcp_persistence_server.py +382 -16
  57. package/src/mcp/mcp_resources_server.py +144 -40
  58. package/src/mcp/pantheon_vision_server.py +1 -1
  59. package/src/mcp/toon_codec.py +491 -0
  60. package/src/pantheon/background-job-board.ts +67 -1
  61. package/src/pantheon/cost-command.ts +60 -39
  62. package/src/pantheon/delegate-manager.ts +669 -0
  63. package/src/pantheon/delegation-classifier.ts +307 -0
  64. package/src/pantheon/delegation-enforce.ts +13 -12
  65. package/src/pantheon/delegation-finalize.ts +374 -0
  66. package/src/pantheon/delegation-notify.ts +212 -0
  67. package/src/pantheon/delegation-toast.ts +73 -0
  68. package/src/pantheon/delegation.ts +1120 -0
  69. package/src/pantheon/goal-loop.ts +2 -2
  70. package/src/pantheon/goal-store.ts +1 -1
  71. package/src/pantheon/hashline/tool.ts +2 -2
  72. package/src/pantheon/model-command.ts +2 -1
  73. package/src/pantheon/permission-eval.ts +284 -0
  74. package/src/pantheon/step-cap.ts +110 -0
  75. package/src/pantheon/token-opt.ts +781 -0
  76. package/src/pantheon/toon-codec.ts +481 -0
  77. package/src/pantheon/tui-version-check.ts +1 -3
  78. package/src/pantheon/v2-bridge.ts +129 -0
  79. package/src/pantheon/v2-events.ts +131 -0
  80. package/src/pantheon/v2-hooks.ts +287 -0
  81. package/src/pantheon/v2-tool-definitions.ts +331 -0
  82. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +824 -0
  85. package/src/plugin.ts +236 -42
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +28 -24
  98. package/src/skills/README.md +0 -2
  99. package/src/pantheon/tool-context.ts +0 -41
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /** Validate the immutable inputs used by the GitHub Release → Zenodo workflow. */
3
+
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { compareVersions } from './version-check.mjs'
8
+
9
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
10
+
11
+ function validateHttpsUrl(value, name) {
12
+ let url
13
+ try {
14
+ url = new URL(value)
15
+ } catch {
16
+ throw new Error(`${name} must be an absolute HTTPS URL.`)
17
+ }
18
+ if (
19
+ url.protocol !== 'https:' ||
20
+ !url.hostname ||
21
+ url.username ||
22
+ url.password ||
23
+ /%(?![0-9A-Fa-f]{2})/.test(value) ||
24
+ /[\s{}\\|^`]/.test(value)
25
+ ) {
26
+ throw new Error(`${name} must be an absolute HTTPS URL without credentials.`)
27
+ }
28
+ return url
29
+ }
30
+
31
+ function maskHost(hostname) {
32
+ if (hostname.length <= 2) return '***'
33
+ return `${hostname[0]}***${hostname.at(-1)}`
34
+ }
35
+
36
+ export function summarizeZenodoConfiguration({
37
+ depositionsUrl,
38
+ filesUrlTemplate,
39
+ publishUrlTemplate,
40
+ creatorName,
41
+ }) {
42
+ const values = [
43
+ ['ZENODO_DEPOSITIONS_URL', depositionsUrl, true],
44
+ ['ZENODO_FILES_URL_TEMPLATE', filesUrlTemplate, true],
45
+ ['ZENODO_PUBLISH_URL_TEMPLATE', publishUrlTemplate, true],
46
+ ['ZENODO_CREATOR_NAME', creatorName, false],
47
+ ]
48
+ return values.map(([name, value, isUrl]) => {
49
+ if (typeof value !== 'string' || !value) return { name, present: false }
50
+ if (!isUrl) return { name, present: true }
51
+ try {
52
+ const url = new URL(value.replaceAll('{id}', '1'))
53
+ return {
54
+ name,
55
+ present: true,
56
+ protocol: url.protocol,
57
+ host: maskHost(url.hostname),
58
+ hasWhitespace: /\s/.test(value),
59
+ hasCredentials: Boolean(url.username || url.password),
60
+ }
61
+ } catch {
62
+ return {
63
+ name,
64
+ present: true,
65
+ protocol: 'invalid',
66
+ host: '***',
67
+ hasWhitespace: /\s/.test(value),
68
+ }
69
+ }
70
+ })
71
+ }
72
+
73
+ export function zenodoTemplateUrl(template, id, name = 'Zenodo URL template') {
74
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Zenodo deposition id is invalid.')
75
+ if (typeof template !== 'string' || !template.includes('{id}'))
76
+ throw new Error(`${name} must contain the {id} placeholder.`)
77
+ return validateHttpsUrl(template.replaceAll('{id}', String(id)), name).href
78
+ }
79
+
80
+ export function zenodoDepositionUrl(depositionsUrl, id) {
81
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Zenodo deposition id is invalid.')
82
+ if (typeof depositionsUrl !== 'string')
83
+ throw new Error('ZENODO_DEPOSITIONS_URL must be an absolute HTTPS URL.')
84
+ const base = validateHttpsUrl(depositionsUrl, 'ZENODO_DEPOSITIONS_URL')
85
+ if (base.search || base.hash)
86
+ throw new Error('ZENODO_DEPOSITIONS_URL must not contain a query or fragment.')
87
+ return new URL(`${base.pathname.replace(/\/$/, '')}/${id}`, base.origin).href
88
+ }
89
+
90
+ export function validateZenodoConfiguration({
91
+ depositionsUrl,
92
+ filesUrlTemplate,
93
+ publishUrlTemplate,
94
+ creatorName,
95
+ }) {
96
+ const base = validateHttpsUrl(depositionsUrl, 'ZENODO_DEPOSITIONS_URL')
97
+ for (const [name, template] of [
98
+ ['ZENODO_FILES_URL_TEMPLATE', filesUrlTemplate],
99
+ ['ZENODO_PUBLISH_URL_TEMPLATE', publishUrlTemplate],
100
+ ]) {
101
+ if (
102
+ typeof template !== 'string' ||
103
+ !template.includes('{id}') ||
104
+ template.replaceAll('{id}', '').match(/[{}]/) ||
105
+ template.includes('{{') ||
106
+ template.includes('}}')
107
+ ) {
108
+ throw new Error(`${name} must be an HTTPS URL template containing the {id} placeholder.`)
109
+ }
110
+ const url = new URL(zenodoTemplateUrl(template, 1, name))
111
+ if (url.origin !== base.origin) throw new Error(`${name} must use the Zenodo depositions host.`)
112
+ }
113
+ if (typeof creatorName !== 'string' || !creatorName.trim())
114
+ throw new Error('ZENODO_CREATOR_NAME must not be empty.')
115
+ return true
116
+ }
117
+
118
+ export function validateZenodoRelease(root, tag) {
119
+ if (!/^v/.test(tag)) throw new Error('Release tag must start with v.')
120
+ const version = tag.slice(1)
121
+ if (!SEMVER.test(version)) throw new Error('Release tag is not valid semver.')
122
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
123
+ if (packageJson.name !== 'pantheon-opencode' || packageJson.version !== version) {
124
+ throw new Error('Release tag does not match package.json.')
125
+ }
126
+ const manifests = compareVersions(root)
127
+ if (!manifests.ok) throw new Error('Release manifests are out of sync.')
128
+ const citationPath = join(root, 'CITATION.cff')
129
+ const citation = { present: existsSync(citationPath), version: null }
130
+ if (citation.present) {
131
+ const match = readFileSync(citationPath, 'utf8').match(/^version:\s*["']?([^"'\s]+)["']?\s*$/m)
132
+ if (!match) throw new Error('CITATION.cff is missing its version field.')
133
+ citation.version = match[1]
134
+ if (citation.version !== version)
135
+ throw new Error('CITATION.cff version differs from the release tag.')
136
+ }
137
+ return { tag, version, citation }
138
+ }
139
+
140
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
141
+ if (isDirectRun) {
142
+ try {
143
+ validateZenodoConfiguration({
144
+ depositionsUrl: process.env.ZENODO_DEPOSITIONS_URL,
145
+ filesUrlTemplate: process.env.ZENODO_FILES_URL_TEMPLATE,
146
+ publishUrlTemplate: process.env.ZENODO_PUBLISH_URL_TEMPLATE,
147
+ creatorName: process.env.ZENODO_CREATOR_NAME,
148
+ })
149
+ console.log(JSON.stringify(validateZenodoRelease(process.cwd(), process.env.RELEASE_TAG ?? '')))
150
+ } catch (error) {
151
+ console.error(`zenodo validation: ${error.message}`)
152
+ process.exit(1)
153
+ }
154
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ export function sha256File(path) {
5
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
6
+ }
7
+
8
+ export function findZenodoFile(record, filename, checksum) {
9
+ if (!record || typeof record !== 'object' || !Array.isArray(record.files)) {
10
+ throw new Error('Zenodo record did not contain a files array.')
11
+ }
12
+ const matches = record.files.filter((file) => file?.filename === filename)
13
+ if (matches.length > 1) throw new Error(`Zenodo record contains duplicate file: ${filename}.`)
14
+ if (matches.length === 0) return null
15
+ const file = matches[0]
16
+ const expected = `sha256:${checksum}`
17
+ if (file.checksum !== expected) {
18
+ throw new Error(`Zenodo checksum mismatch for ${filename}.`)
19
+ }
20
+ return file
21
+ }
@@ -0,0 +1,53 @@
1
+ const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/
2
+ const MARKER_PATTERN =
3
+ /<!-- zenodo-deposition:(?:id=(\d+);)?(?:doi=([^;\s]+);)?state=(pending|created|published)(?:;claim=([^\s]+))? -->/
4
+
5
+ export function extractZenodoDoi(response) {
6
+ if (!response || typeof response !== 'object') return null
7
+ const candidates = [response.doi, response.metadata?.doi, response.links?.doi]
8
+ for (const candidate of candidates) {
9
+ if (typeof candidate === 'string' && DOI_PATTERN.test(candidate.trim())) return candidate.trim()
10
+ }
11
+ return null
12
+ }
13
+
14
+ export function validateZenodoResponse(response, { requireDoi = false } = {}) {
15
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
16
+ throw new Error('Zenodo response must be a JSON object.')
17
+ }
18
+ if (!Number.isSafeInteger(response.id) || response.id <= 0) {
19
+ throw new Error('Zenodo response did not contain a valid deposition id.')
20
+ }
21
+ const doi = extractZenodoDoi(response)
22
+ if (requireDoi && !doi) throw new Error('Zenodo response did not contain a valid DOI.')
23
+ return { id: response.id, doi }
24
+ }
25
+
26
+ export function parseZenodoMarker(body) {
27
+ const text = String(body ?? '')
28
+ const match = text.match(MARKER_PATTERN)
29
+ if (!match) {
30
+ const legacy = text.match(/<!-- zenodo-deposition-id:(\d+) -->/)
31
+ if (!legacy) return null
32
+ const id = Number(legacy[1])
33
+ if (!Number.isSafeInteger(id) || id <= 0)
34
+ throw new Error('Zenodo marker has an invalid deposition id.')
35
+ return { id, doi: null, state: 'created', claim: null }
36
+ }
37
+ const id = match[1] ? Number(match[1]) : null
38
+ if (match[1] && (!Number.isSafeInteger(id) || id <= 0))
39
+ throw new Error('Zenodo marker has an invalid deposition id.')
40
+ if (match[2] && !DOI_PATTERN.test(match[2])) throw new Error('Zenodo marker has an invalid DOI.')
41
+ return { id, doi: match[2] ?? null, state: match[3], claim: match[4] ?? null }
42
+ }
43
+
44
+ export function zenodoMarker({ id = null, doi = null, state, claim = null }) {
45
+ if (!['pending', 'created', 'published'].includes(state))
46
+ throw new Error('Zenodo marker state is invalid.')
47
+ if (id !== null && (!Number.isSafeInteger(id) || id <= 0))
48
+ throw new Error('Zenodo marker id is invalid.')
49
+ if (doi !== null && !DOI_PATTERN.test(doi)) throw new Error('Zenodo marker DOI is invalid.')
50
+ if (state !== 'pending' && id === null)
51
+ throw new Error('Created or published Zenodo markers require an id.')
52
+ return `<!-- zenodo-deposition:${id === null ? '' : `id=${id};`}${doi === null ? '' : `doi=${doi};`}state=${state}${claim === null ? '' : `;claim=${claim}`} -->`
53
+ }
@@ -144,23 +144,33 @@ task_status(task_id="ses_xxx", wait=true)
144
144
  -> bloqueia ate completar: { state: "completed", task_result: "..." }
145
145
  ```
146
146
 
147
- ### Pantheon Delegation — BackgroundJobBoard (plugin)
147
+ ### Pantheon Delegation Tools (plugin) — 3-tool API
148
148
 
149
- O plugin Pantheon mantém um **BackgroundJobBoard** persistente
150
- (`.pantheon/board/state.json`) que rastreia trabalho em voo e o carrega para o
151
- contexto de compactação. O toolset de delegação do plugin
152
- (`pantheon_delegate`, `pantheon_delegation_read`, `pantheon_delegation_list`)
153
- foi **removido em 1.4.3** — dispatch e coleta usam a API nativa
154
- `task(background=true)` / `task_status(wait=true)`.
149
+ O plugin Pantheon adiciona 3 tools de delegacao gerenciada pelo BackgroundJobBoard:
155
150
 
151
+ ```
152
+ pantheon_delegate({prompt, agent, description?, read_only?})
153
+ -> cria sessao filha, registra no board, retorna alias: [apo-1]
154
+ pantheon_delegation_read({id: "apo-1"})
155
+ -> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
156
+ -> durante a espera, mostra o que o agente esta fazendo: seccao final "## Agent Activity"
157
+ (amostra as messages do child a cada ~2s; tool calls + args truncados / texto)
158
+ pantheon_delegation_list()
159
+ -> lista jobs da sessao, com [unread] para jobs terminados nao lidos
160
+ -> jobs RUNNING mostram "last activity:" (ultima acao visivel do child)
161
+ -> fail-open: sem suporte a messages, formato atual inalterado
162
+ ```
163
+
164
+ - **Read-only enforcement:** agentes read-only (apollo, gaia — `read_only_agents` no routing.yml) tem `edit`, `write`, `bash`, `task` NEGADOS dentro da sessao delegada (o guard de `tool.execute.before` THROW com mensagem acionavel). Use `read_only: true` no delegate para qualquer agente de investigacao.
165
+ - **Depth-2 hard-enforced:** sessao read-only NAO cria subagentes (`task` bloqueado) — investigacao nunca vira arvore.
156
166
  - **Compactacao:** jobs em voo (running + unread terminal) sao injetados no contexto de compactacao via `experimental.session.compacting` — delegacao em andamento nao se perde ao compactar.
157
167
 
158
- ### DELEGATION_RULES (native task path)
168
+ ### DELEGATION_RULES (plugin path)
159
169
 
160
- 1. **NUNCA** polle `task_status` para checar se um job terminou — use `wait=true` para fan-in explicito.
161
- 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` no board, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
162
- 3. Use `task_status({task_id, wait: true})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
163
- 4. `task_status` sem `wait` e para diagnostico, nao para polling.
170
+ 1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
171
+ 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` em `pantheon_delegation_list`, `pantheon_delegation_read({id})`, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
172
+ 3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
173
+ 4. `pantheon_delegation_list` e para diagnostico, nao para polling.
164
174
 
165
175
  ### Background (sempre usar)
166
176
  - **Apollo, Hermes, Aphrodite, Demeter, Hephaestus, Prometheus**
@@ -314,8 +324,9 @@ out = await guard.maybeRetry(result, async () => { ...task() de novo... })
314
324
  Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
315
325
  nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
316
326
 
317
- > **Waves DEVEM usar `dispatch-guard`** (`src/pantheon/dispatch-guard.ts`) — encapsula `task → classify → retry 1x → escalate` com cap duro 1; se ainda vazio throw `ZeusEscalationError` (escale: tente outro agente, simplifique, manual).
318
- > Uso alto-nível: `import { createDispatchGuard } from './pantheon/dispatch-guard.ts'` → `await guard.maybeRetry(result, () => task(...))`.
327
+ > **Waves DEVEM usar `zeusDelegateWithRetry`** (`src/pantheon/zeus-delegate-with-retry.ts`) — encapsula `delegate → waitForTerminal → classify → retry 1x → escalate` com cap duro 1; se ainda vazio throw `ZeusEscalationError` (escale: tente outro agente, simplifique, manual).
328
+ > Uso alto-nível: `import { zeusDelegateWithRetry } from './pantheon/zeus-delegate-with-retry.ts'` → `await zeusDelegateWithRetry({ board, client, sessionID, agent, prompt })`.
329
+ > Uso baixo-nível: `createZeusRetryHelper().executeWithRetry(first, () => secondRead())` — classifica empty e decide retry/escalate (mesmo cap 1).
319
330
 
320
331
  ### /cost — pantheon_cost tool (WIRED no plugin)
321
332
  `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
@@ -34,11 +34,11 @@ def pantheon_home() -> Path:
34
34
  """
35
35
  env = os.environ.get("PANTHEON_HOME")
36
36
  if env:
37
- return Path(env).expanduser().resolve()
37
+ return Path(env).expanduser().absolute()
38
38
 
39
39
  xdg = os.environ.get("XDG_CONFIG_HOME")
40
40
  if xdg:
41
- return Path(xdg).expanduser().resolve() / "opencode"
41
+ return Path(xdg).expanduser().absolute() / "opencode"
42
42
 
43
43
  return Path.home() / ".config" / "opencode"
44
44
 
@@ -48,7 +48,8 @@ def pantheon_project() -> Path | None:
48
48
 
49
49
  Resolution priority:
50
50
  1. $PANTHEON_PROJECT env var (explicit override)
51
- 2. Current working directory (set by MCP client's cwd in opencode.json)
51
+ 2. The validated logical ``$PWD`` supplied by the launcher
52
+ 3. Current working directory (set by MCP client's cwd in opencode.json)
52
53
 
53
54
  Returns:
54
55
  Absolute Path to the project root, or None if neither is available
@@ -56,10 +57,50 @@ def pantheon_project() -> Path | None:
56
57
  """
57
58
  env = os.environ.get("PANTHEON_PROJECT")
58
59
  if env:
59
- return Path(env).expanduser().resolve()
60
-
61
- cwd = os.getcwd()
60
+ return Path(env).expanduser().absolute()
61
+
62
+ logical_cwd = os.environ.get("PWD")
63
+ try:
64
+ cwd = os.getcwd()
65
+ except OSError:
66
+ cwd = ""
67
+ if logical_cwd and _is_valid_logical_cwd(logical_cwd, cwd):
68
+ return Path(logical_cwd).expanduser().absolute()
62
69
  if cwd:
63
- return Path(cwd).resolve()
70
+ return Path(cwd).absolute()
64
71
 
65
72
  return None
73
+
74
+
75
+ def _is_valid_logical_cwd(logical_cwd: str, physical_cwd: str) -> bool:
76
+ """Validate a launcher-provided logical cwd without resolving its result.
77
+
78
+ ``PWD`` is trusted only when it is an existing directory naming the same
79
+ inode as the process cwd. This preserves symlink spelling while rejecting
80
+ forged or stale environment values.
81
+ """
82
+ logical = Path(logical_cwd).expanduser()
83
+ if not logical.is_absolute() or not logical.is_dir():
84
+ return False
85
+ try:
86
+ return os.path.samefile(logical, physical_cwd)
87
+ except (OSError, ValueError):
88
+ return False
89
+
90
+
91
+ def has_symlink_component(path: Path) -> bool:
92
+ """Return whether any existing lexical component of ``path`` is a symlink.
93
+
94
+ The lexical path is intentionally inspected before ``resolve()`` so a
95
+ symlink cannot be hidden by resolving to an otherwise trusted directory.
96
+ Missing trailing components are allowed; this keeps normal project paths
97
+ usable while still checking every existing ancestor.
98
+ """
99
+ current = Path(path.anchor) if path.is_absolute() else Path.cwd()
100
+ for component in path.parts[1:] if path.is_absolute() else path.parts:
101
+ if component in {"", "."}:
102
+ continue
103
+ current /= component
104
+ if current.is_symlink():
105
+ return True
106
+ return False
@@ -18,8 +18,8 @@ from __future__ import annotations
18
18
  import asyncio
19
19
  import logging
20
20
  import os
21
- import signal
22
21
  import shutil
22
+ import signal
23
23
  import stat
24
24
  import sys
25
25
  import time
@@ -28,7 +28,6 @@ from pathlib import Path
28
28
  from typing import Any
29
29
 
30
30
  import yaml
31
-
32
31
  from _pantheon_paths import pantheon_home, pantheon_project
33
32
  from mcp.server.fastmcp import FastMCP
34
33
 
@@ -133,6 +132,34 @@ def _detect_prlimit() -> str | None:
133
132
  # 1. /.opencode/.pantheon/code-mode/ (project install)
134
133
  # 2. /.pantheon/code-mode/ (legacy fallback)
135
134
  # 3. /.pantheon/code-mode/ (global fallback)
135
+ # 4. .pantheon/code-mode/ shipped inside the installed package (tarball
136
+ # fallback — package.json `files` includes .pantheon/code-mode/**)
137
+
138
+
139
+ def _packaged_scripts_dir() -> Path | None:
140
+ """Return the code-mode dir shipped inside the installed package, if any.
141
+
142
+ Walks up from this file looking for a `.pantheon/code-mode/` directory
143
+ (e.g. `<pkg>/.pantheon/code-mode` when installed from the tarball).
144
+ Returns None when no packaged copy exists.
145
+ """
146
+ try:
147
+ here = Path(__file__).resolve()
148
+ except Exception:
149
+ return None
150
+ for parent in [here.parent, *here.parents]:
151
+ try:
152
+ candidate = parent / ".pantheon" / "code-mode"
153
+ except Exception:
154
+ continue
155
+ try:
156
+ if candidate.is_dir():
157
+ return candidate
158
+ except OSError:
159
+ continue
160
+ return None
161
+
162
+
136
163
  _PANTHEON_HOME: Path = pantheon_home()
137
164
  _SCRIPTS_DIR_CANDIDATES: list[Path] = []
138
165
  _proj = pantheon_project()
@@ -146,6 +173,10 @@ for _candidate in _SCRIPTS_DIR_CANDIDATES:
146
173
  if _candidate.is_dir():
147
174
  SCRIPTS_DIR = _candidate
148
175
  break
176
+ else:
177
+ _packaged = _packaged_scripts_dir()
178
+ if _packaged is not None:
179
+ SCRIPTS_DIR = _packaged
149
180
 
150
181
  # ── FastMCP App ───────────────────────────────────────────────────────────────
151
182
  mcp = FastMCP(
@@ -138,7 +138,7 @@ def store_eval(
138
138
  except sqlite3.IntegrityError:
139
139
  conn.rollback()
140
140
  return {"error": f"Duplicate key or constraint violation: {key}"}
141
- except Exception as e: # noqa: BLE001
141
+ except Exception as e:
142
142
  conn.rollback()
143
143
  return {"error": f"Failed to store eval: {e}"}
144
144
  finally: