@frontera-sdk/cli 1.52.43 → 1.52.45

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.
@@ -587,6 +587,53 @@ export function ensureProjectBunfig(root: string): boolean {
587
587
  return true
588
588
  }
589
589
 
590
+ /** The example Agent, workflow, Function, evaluation and object type, with the sample dataset they use. */
591
+ function sampleFiles(name: string, agentModel?: string): Record<string, string> {
592
+ return {
593
+ 'agents/assistant/agent.json': AGENT(name, agentModel),
594
+ 'agents/assistant/operating.json': OPERATING,
595
+ 'agents/assistant/bindings.json': BINDINGS,
596
+ 'agents/assistant/evals/regression.yaml': EVALS,
597
+ 'workflows/hello.yaml': WORKFLOW,
598
+ 'workflows/tests/hello.yaml': WORKFLOW_TESTS,
599
+ 'functions/word-count.ts': FUNCTION,
600
+ 'functions/word-count.test.ts': FUNCTION_TEST,
601
+ 'datasets/tickets.json': DATASET,
602
+ 'datasets/tickets.csv': DATASET_ROWS,
603
+ 'blueprint/object-types/Ticket.yaml': OBJECT_TYPE,
604
+ }
605
+ }
606
+
607
+ /** The sample Agent as compared: its model and the project's name in its description are set per project. */
608
+ function sampleAgent(text: string): unknown {
609
+ try {
610
+ const doc = JSON.parse(text) as { config?: Record<string, unknown>; modelConfigs?: unknown }
611
+ const { description: _description, defaultMode: _defaultMode, ...config } = doc.config ?? {}
612
+ const { modelConfigs: _models, ...rest } = doc
613
+ return { ...rest, config }
614
+ } catch {
615
+ return null
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Whether an item's files are still exactly what setting up the project wrote
621
+ * for its sample: the example, not the project's own work, so a linked
622
+ * project does not propose it by default: the sample Ticket type would
623
+ * otherwise overwrite a workspace's own. Edited, it is the project's.
624
+ */
625
+ export function isScaffoldSample(files: Record<string, string>): boolean {
626
+ const sample = sampleFiles('')
627
+ const paths = Object.keys(files)
628
+ if (!paths.length) return false
629
+ return paths.every((path) => {
630
+ const expected = sample[path]
631
+ if (expected === undefined) return false
632
+ if (path === 'agents/assistant/agent.json') return JSON.stringify(sampleAgent(files[path]!)) === JSON.stringify(sampleAgent(expected))
633
+ return files[path] === expected
634
+ })
635
+ }
636
+
590
637
  /** `samples: false` leaves out the example Agent, workflow, Function and object type: a project started from a workspace gets the workspace's own. */
591
638
  export function solutionScaffoldFiles(name: string, opts: { agentModel?: string; samples?: boolean } = {}): Record<string, string> {
592
639
  const sdk = vendored.files as Record<string, string>
@@ -610,19 +657,7 @@ export function solutionScaffoldFiles(name: string, opts: { agentModel?: string;
610
657
  out['.codex/config.toml'] = CODEX_MCP_TOML
611
658
  out['fixtures/README.md'] = FIXTURES_README
612
659
  out['bunfig.toml'] = PROJECT_BUNFIG
613
- if (opts.samples !== false) {
614
- out['agents/assistant/agent.json'] = AGENT(name, opts.agentModel)
615
- out['agents/assistant/operating.json'] = OPERATING
616
- out['agents/assistant/bindings.json'] = BINDINGS
617
- out['agents/assistant/evals/regression.yaml'] = EVALS
618
- out['workflows/hello.yaml'] = WORKFLOW
619
- out['workflows/tests/hello.yaml'] = WORKFLOW_TESTS
620
- out['functions/word-count.ts'] = FUNCTION
621
- out['functions/word-count.test.ts'] = FUNCTION_TEST
622
- out['datasets/tickets.json'] = DATASET
623
- out['datasets/tickets.csv'] = DATASET_ROWS
624
- out['blueprint/object-types/Ticket.yaml'] = OBJECT_TYPE
625
- }
660
+ if (opts.samples !== false) Object.assign(out, sampleFiles(name, opts.agentModel))
626
661
  out['package.json'] =
627
662
  JSON.stringify(
628
663
  {
@@ -134,7 +134,7 @@ const EVALS_SCHEMA: JsonSchema = {
134
134
  conversation: {
135
135
  type: 'array',
136
136
  minItems: 1,
137
- description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before).',
137
+ description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before). A case of more than one turn runs on this computer only: the workspace\'s evaluation datasets hold single questions, so `frontera local sync push` proposes the file\'s single-question cases and keeps the rest here.',
138
138
  items: {
139
139
  type: 'object',
140
140
  properties: {
@@ -166,7 +166,7 @@ export async function seedLocalRuntime(args: SeedArgs): Promise<LocalCredentials
166
166
  }
167
167
 
168
168
  /** Provider ids the service stores as organization credentials, each with an `apiKey` field. */
169
- const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google'])
169
+ const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google', 'openrouter-web-search'])
170
170
 
171
171
  /** Embedding defaults by provider, in preference order; the service requires 1536-wide vectors. */
172
172
  const EMBEDDING: Array<{ provider: string; model: string }> = [
@@ -299,7 +299,7 @@ export function startStudioServer(opts: StudioServerOptions) {
299
299
  if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
300
300
  const name = checkUpload((file as File).name ?? '', file.size)
301
301
  const replace = url.searchParams.get('replace') === '1'
302
- // Replacing: the runtime's copy goes first, so the apply uploads the new content (it only adds what the runtime lacks).
302
+ // Replacing: the runtime's copy goes first, so the apply uploads the new content as a document the runtime lacks.
303
303
  if (replace && id && documentInFolder(project.root, folder, name)) {
304
304
  const call = serviceCall()
305
305
  if (call) {
@@ -386,14 +386,20 @@ export function startStudioServer(opts: StudioServerOptions) {
386
386
  if (state?.credentials?.workspaceId && !headers.has('x-workspace-id')) {
387
387
  headers.set('x-workspace-id', state.credentials.workspaceId)
388
388
  }
389
+ // A page sends the session token it kept (`bearer_token`) itself. After
390
+ // `frontera local reset` that session is gone, so a session the service
391
+ // refuses is answered as the owner; an API key or a signed token is not.
392
+ const kept = headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9]{32})$/)?.[1]
393
+ const fallback = kept && bearer && kept !== bearer ? bearer : null
394
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
395
+ const body = hasBody && fallback ? await req.arrayBuffer() : hasBody ? req.body : undefined
389
396
  try {
390
- const res = await fetch(target, {
391
- method: req.method,
392
- headers,
393
- body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
394
- redirect: 'manual',
395
- duplex: 'half',
396
- })
397
+ const send = () => fetch(target, { method: req.method, headers, body, redirect: 'manual', duplex: 'half' })
398
+ let res = await send()
399
+ if (res.status === 401 && fallback) {
400
+ headers.set('authorization', `Bearer ${fallback}`)
401
+ res = await send()
402
+ }
397
403
  const out = new Headers(res.headers)
398
404
  out.delete('content-encoding')
399
405
  out.delete('content-length')
@@ -32,6 +32,7 @@ import {
32
32
  type LocalConfig,
33
33
  type LocalSecrets,
34
34
  } from './local-env'
35
+ import { runtimeKeyCustodianProblem } from './local-key-custodian'
35
36
  import { localEmbeddingsReachable, providerEnv } from './local-keys'
36
37
  import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
37
38
  import type { PortBlock } from './local-ports'
@@ -484,6 +485,7 @@ async function startService(
484
485
  running.children.set('service', child)
485
486
  const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 120_000, () => child.exitCode === null)
486
487
  if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
488
+ await refuseBrokenKeyCustodian(ports)
487
489
  set('service', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
488
490
  keepDocs(project, { bundle: customerDocsBundle(config.platformSource) }, log)
489
491
  refreshCheckoutDocs(project, config.platformSource, log)
@@ -508,10 +510,21 @@ async function startService(
508
510
  followLogs(name, log)
509
511
  const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 180_000)
510
512
  if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
513
+ await refuseBrokenKeyCustodian(ports)
511
514
  set('service', { status: 'running', container: name, startedAt: new Date().toISOString() })
512
515
  keepDocs(project, { fromContainer: (dest) => copyFromContainer(name, IMAGE_DOCS_PATH, dest) }, log)
513
516
  }
514
517
 
518
+ /**
519
+ * A service whose encryption keys can't be read fails every secret and
520
+ * governed Action record later with a 500: the start stops here instead, with
521
+ * the fix (`local-key-custodian.ts`).
522
+ */
523
+ async function refuseBrokenKeyCustodian(ports: PortBlock): Promise<void> {
524
+ const problem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${ports.service}`)
525
+ if (problem) throw new Error(problem)
526
+ }
527
+
515
528
  /** The release's documentation, for the solution's coding agents (`frontera mcp`). */
516
529
  function keepDocs(project: LocalProject, from: { bundle?: string | null; fromContainer?: (dest: string) => boolean }, log: string): void {
517
530
  if (!from.bundle && !from.fromContainer) {
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { parseDocument } from 'yaml'
@@ -28,6 +28,11 @@ export interface SyncLink {
28
28
  /** Names shown in Forge and `sync status`; refreshed on connect. */
29
29
  workspaceName?: string
30
30
  organizationName?: string
31
+ /**
32
+ * A model this computer names (a local provider's, by catalog slug) and the
33
+ * workspace's model it goes as when proposed (`local-sync-portable.ts`).
34
+ */
35
+ models?: Record<string, string>
31
36
  }
32
37
 
33
38
  export function projectFile(root: string): string {
@@ -40,6 +45,9 @@ export function readLink(root: string): SyncLink | null {
40
45
  const doc = parseDocument(readFileSync(file, 'utf8')).toJS() as { sync?: Partial<SyncLink> } | null
41
46
  const s = doc?.sync
42
47
  if (!s || typeof s.platform !== 'string' || typeof s.workspace !== 'string') return null
48
+ const models = s.models && typeof s.models === 'object' && !Array.isArray(s.models)
49
+ ? Object.fromEntries(Object.entries(s.models as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string' && !!e[0] && !!e[1]))
50
+ : {}
43
51
  return {
44
52
  platform: s.platform,
45
53
  workspace: s.workspace,
@@ -47,9 +55,41 @@ export function readLink(root: string): SyncLink | null {
47
55
  blueprint: s.blueprint === 'organization' ? 'organization' : 'workspace',
48
56
  workspaceName: typeof s.workspaceName === 'string' ? s.workspaceName : undefined,
49
57
  organizationName: typeof s.organizationName === 'string' ? s.organizationName : undefined,
58
+ ...(Object.keys(models).length ? { models } : {}),
50
59
  }
51
60
  }
52
61
 
62
+ const mapped = new Map<string, { mtimeMs: number; models: Record<string, string> }>()
63
+
64
+ /** The project's model mapping (`sync.models`), empty when it has none or no link; read once per change of the file. */
65
+ export function modelMap(root: string): Record<string, string> {
66
+ const file = projectFile(root)
67
+ let mtimeMs = -1
68
+ try {
69
+ mtimeMs = statSync(file).mtimeMs
70
+ } catch {
71
+ return {}
72
+ }
73
+ const known = mapped.get(file)
74
+ if (known && known.mtimeMs === mtimeMs) return known.models
75
+ const models = readLink(root)?.models ?? {}
76
+ mapped.set(file, { mtimeMs, models })
77
+ return models
78
+ }
79
+
80
+ /** Map a local model to a workspace model when proposed, or (`workspace` null) stop mapping it. */
81
+ export function setModelMapping(root: string, local: string, workspace: string | null): SyncLink {
82
+ const link = readLink(root)
83
+ if (!link) throw new Error('the project is not linked to a workspace')
84
+ const models = { ...(link.models ?? {}) }
85
+ if (workspace) models[local] = workspace
86
+ else delete models[local]
87
+ const next: SyncLink = { ...link, models }
88
+ if (!Object.keys(models).length) delete next.models
89
+ writeLink(root, next)
90
+ return next
91
+ }
92
+
53
93
  /** Write the link into `frontera.yaml`, keeping the rest of the file and its comments. */
54
94
  export function writeLink(root: string, link: SyncLink | null): void {
55
95
  const file = projectFile(root)
@@ -58,11 +98,12 @@ export function writeLink(root: string, link: SyncLink | null): void {
58
98
  if (!link) {
59
99
  doc.delete('sync')
60
100
  } else {
61
- const value: Record<string, string> = { platform: link.platform, workspace: link.workspace }
101
+ const value: Record<string, unknown> = { platform: link.platform, workspace: link.workspace }
62
102
  if (link.workspaceName) value.workspaceName = link.workspaceName
63
103
  if (link.organization) value.organization = link.organization
64
104
  if (link.organizationName) value.organizationName = link.organizationName
65
105
  value.blueprint = link.blueprint
106
+ if (link.models && Object.keys(link.models).length) value.models = link.models
66
107
  const existing = doc.get('sync', true) as { commentBefore?: string } | undefined
67
108
  const node = doc.createNode(value) as unknown as { commentBefore?: string }
68
109
  node.commentBefore = existing?.commentBefore ?? ' The platform workspace this project pulls from (`frontera local sync`). Keys are in your keychain, not here.'
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { modelMap, projectFile } from './local-sync-link'
5
+ import type { ModelMap } from './local-sync-portable'
6
+
7
+ /**
8
+ * Which model a project's model slug is, on this computer and in the
9
+ * workspace. A catalog slug is each catalog's own name for a model (this
10
+ * computer's provider calls it `deepseek-deepseek-v4-1-flash`, the
11
+ * workspace's `deepseek-v4-1-flash`), so models are matched by what they are:
12
+ * the provider's model id (`deepseek/deepseek-v4.1-flash`).
13
+ *
14
+ * the same model in the workspace the file's slug is written as the workspace's when proposed, and the
15
+ * workspace's as this computer's on pull: nothing to choose
16
+ * no such model in the workspace which model an Agent runs on stays the workspace's choice: the Agent
17
+ * keeps the workspace's model there (its default, for a new one), and the
18
+ * local one is this computer's own, never proposed (`local-sync-portable.ts`)
19
+ * `sync.models` in frontera.yaml an explicit choice, over the match
20
+ *
21
+ * This computer's catalog is kept when a sync command finds the runtime
22
+ * running (`.frontera/local/model-catalog.json`), so status and push match
23
+ * models offline; the workspace's comes with the snapshot.
24
+ */
25
+
26
+ export interface CatalogEntry {
27
+ slug: string
28
+ name?: string
29
+ kind?: string
30
+ provider?: string
31
+ modelId?: string
32
+ }
33
+
34
+ const LOCAL_CATALOG = join('.frontera', 'local', 'model-catalog.json')
35
+ const REMOTE_JSON = join('.frontera', 'sync', 'remote.json')
36
+
37
+ /** Keep this computer's catalog, as the running runtime listed it. */
38
+ export function rememberLocalCatalog(root: string, models: CatalogEntry[]): void {
39
+ const file = join(root, LOCAL_CATALOG)
40
+ try {
41
+ mkdirSync(dirname(file), { recursive: true })
42
+ writeFileSync(file, `${JSON.stringify({ version: 1, models: models.map(({ slug, name, kind, provider, modelId }) => ({ slug, name, kind, provider, modelId })) }, null, 2)}\n`)
43
+ } catch {
44
+ // Matching then falls back to the last copy, or to slugs alone.
45
+ }
46
+ }
47
+
48
+ function readJson<T>(file: string): T | null {
49
+ try {
50
+ return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /** The provider's id as two catalogs write it alike: case and spaces aside. */
57
+ const sameModelId = (id: string | undefined) => (id ?? '').trim().toLowerCase()
58
+
59
+ export interface ProjectModels {
60
+ /** A local slug and the workspace slug it goes as: matched by model, then `sync.models` over it. */
61
+ map: ModelMap
62
+ /** The workspace's model slugs; null when no snapshot lists them (then nothing is judged missing). */
63
+ workspace: Set<string> | null
64
+ /** The workspace's models by slug, for words. */
65
+ names: Record<string, string>
66
+ }
67
+
68
+ const cached = new Map<string, { stamp: string; value: ProjectModels }>()
69
+
70
+ const stampOf = (...files: string[]) =>
71
+ files
72
+ .map((f) => {
73
+ try {
74
+ return String(statSync(f).mtimeMs)
75
+ } catch {
76
+ return '-'
77
+ }
78
+ })
79
+ .join('|')
80
+
81
+ /** The project's models as push and status read them; read again only when one of their files changes. */
82
+ export function projectModels(root: string): ProjectModels {
83
+ const files = [projectFile(root), join(root, LOCAL_CATALOG), join(root, REMOTE_JSON)]
84
+ const stamp = stampOf(...files)
85
+ const known = cached.get(root)
86
+ if (known && known.stamp === stamp) return known.value
87
+ const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> } }>(join(root, REMOTE_JSON))
88
+ const theirs = remote?.models ?? Object.keys(remote?.names?.model ?? {}).map((slug) => ({ slug }) as CatalogEntry)
89
+ const ours = readJson<{ models?: CatalogEntry[] }>(join(root, LOCAL_CATALOG))?.models ?? []
90
+ const value = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
91
+ cached.set(root, { stamp, value })
92
+ return value
93
+ }
94
+
95
+ /** The match itself: each local model the workspace has under another slug, by the provider's id; the explicit choices over it. */
96
+ export function matchModels(ours: CatalogEntry[], theirs: CatalogEntry[] | null, explicit: ModelMap = {}): ProjectModels {
97
+ if (!theirs) return { map: { ...explicit }, workspace: null, names: {} }
98
+ const workspace = new Set(theirs.map((m) => m.slug))
99
+ const byId = new Map<string, CatalogEntry[]>()
100
+ for (const m of theirs) {
101
+ if ((m.kind ?? 'text') !== 'text' || !m.modelId) continue
102
+ byId.set(sameModelId(m.modelId), [...(byId.get(sameModelId(m.modelId)) ?? []), m])
103
+ }
104
+ // A platform that lists its models without the provider's id (older than these fields): a slug made from the
105
+ // provider's id, whole or its model part (`deepseek/deepseek-v4.1-flash` → `deepseek-v4-1-flash`), is the same model.
106
+ const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
107
+ const bySlug = new Map(theirs.filter((t) => !t.modelId).map((t) => [slugify(t.slug), t]))
108
+ const map: ModelMap = {}
109
+ for (const m of ours) {
110
+ if (workspace.has(m.slug) || !m.modelId) continue
111
+ const guessed = [slugify(m.modelId), slugify(m.modelId.split('/').pop() ?? '')].map((s) => bySlug.get(s)).find(Boolean)
112
+ const same = byId.get(sameModelId(m.modelId)) ?? (guessed ? [guessed] : [])
113
+ // One model under two of the workspace's providers: the one named alike, else the first.
114
+ const pick = same.find((w) => w.name && w.name === m.name) ?? same[0]
115
+ if (pick) map[m.slug] = pick.slug
116
+ }
117
+ return { map: { ...map, ...explicit }, workspace, names: Object.fromEntries(theirs.map((m) => [m.slug, m.name ?? m.slug])) }
118
+ }
@@ -9,6 +9,8 @@ import { conflictsId, deepEqual, mergeItemFiles, type Choice, type Side } from '
9
9
  import { itemTitle } from './local-sync-names'
10
10
  import { toNames, type Directory, type Unresolved } from './local-sync-refs'
11
11
  import { comparableText, isPluginFile, withLocalSecrets } from './local-secrets'
12
+ import { projectModels } from './local-sync-models'
13
+ import { comparablePortable, toLocal } from './local-sync-portable'
12
14
 
13
15
  /**
14
16
  * A pull is a plan first. Each item in the workspace (an Agent with its
@@ -314,17 +316,24 @@ export function statusFor(base: string | null, local: string | null, remote: str
314
316
  return 'same'
315
317
  }
316
318
 
317
- /** A working file as it compares with the workspace's: a Plugin's secret values, this computer's own, left out. */
318
- function readComparable(root: string, rel: string, base?: (path: string) => string | null): string | null {
319
+ /**
320
+ * A working file as it compares with the workspace's: what is this
321
+ * computer's own left out (a Plugin's secret values, a model mapped in
322
+ * `sync.models`, multi-turn evaluation cases), and written as the
323
+ * workspace's or the base's text when it then says the same.
324
+ */
325
+ function readComparable(root: string, rel: string, base?: (path: string) => string | null, remote?: string | null): string | null {
319
326
  const text = readLocal(root, rel)
320
- return text === null ? null : comparableText(rel, text, isPluginFile(rel) ? (base?.(rel) ?? null) : null)
327
+ if (text === null) return null
328
+ if (isPluginFile(rel)) return comparableText(rel, text, base?.(rel) ?? null)
329
+ return comparablePortable(rel, text, projectModels(root), [remote, base?.(rel)])
321
330
  }
322
331
 
323
332
  /** Hash of the working files at these paths, or null when none of them exist. */
324
- function localHash(root: string, paths: string[], base?: (path: string) => string | null): string | null {
333
+ function localHash(root: string, paths: string[], base?: (path: string) => string | null, remote: Record<string, string> = {}): string | null {
325
334
  const files: Record<string, string> = {}
326
335
  for (const p of paths) {
327
- const content = readComparable(root, p, base)
336
+ const content = readComparable(root, p, base, remote[p])
328
337
  if (content !== null) files[p] = content
329
338
  }
330
339
  return Object.keys(files).length ? itemHash(files) : null
@@ -401,7 +410,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
401
410
  seen.add(item.ref)
402
411
  const entry = lock?.items[item.ref]
403
412
  const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
404
- const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
413
+ const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base, item.files[path] ?? null), remote: item.files[path] ?? null }))
405
414
  // An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
406
415
  // `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
407
416
  const added = item.kind === 'agent' ? AGENT_FILES.map((f) => `agents/${item.name}/${f}`).filter((p) => !paths.includes(p) && existsSync(join(root, p))) : []
@@ -409,7 +418,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
409
418
  // The files as they stand over the base's paths, plus any path only the workspace has that is
410
419
  // already in the folder: a file the pull would overwrite counts as a change here.
411
420
  const basePaths = entry ? [...Object.keys(entry.files), ...files.filter((f) => !(f.path in entry.files) && f.local !== null).map((f) => f.path), ...added] : Object.keys(item.files)
412
- const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base), itemHash(item.files), matches) ?? 'same'
421
+ const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base, item.files), itemHash(item.files), matches) ?? 'same'
413
422
  // Pulled with conflicts and not resolved: it stays so until `resolve`, whatever the files say.
414
423
  const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
415
424
  let merge: PlanItem['merge']
@@ -579,8 +588,9 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
579
588
  for (const [path, remote] of Object.entries(item.files)) {
580
589
  const full = safePath(root, path)
581
590
  mkdirSync(dirname(full), { recursive: true })
582
- // A Plugin's secret values set here stay: they are this computer's configuration.
583
- const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : remote
591
+ // A Plugin's secret values set here stay: they are this computer's configuration. So do a mapped model and
592
+ // the multi-turn evaluation cases (`local-sync-portable.ts`).
593
+ const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : toLocal(path, remote, readLocal(root, path), projectModels(root), opts.base?.(path) ?? null)
584
594
  if (readLocal(root, path) !== content) {
585
595
  writeFileSync(full, content)
586
596
  written.push(path)