@frontera-sdk/cli 1.52.44 → 1.52.46

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 (110) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/kit/vendor.ts +62 -48
  33. package/src/commands/local/check.ts +57 -11
  34. package/src/commands/local/data.ts +75 -7
  35. package/src/commands/local/dev.ts +77 -9
  36. package/src/commands/local/embeddings.ts +74 -0
  37. package/src/commands/local/index-commands.ts +133 -11
  38. package/src/commands/local/init.ts +22 -2
  39. package/src/commands/local/new.ts +78 -0
  40. package/src/commands/local/publish.ts +34 -0
  41. package/src/commands/local/steps.ts +105 -0
  42. package/src/commands/local/sync-request.ts +69 -23
  43. package/src/commands/local/sync.ts +2 -0
  44. package/src/commands/media/index-commands.ts +58 -3
  45. package/src/commands/registry.ts +11 -3
  46. package/src/commands/trace/index-commands.ts +56 -0
  47. package/src/commands/workspace/index-commands.ts +14 -7
  48. package/src/config.ts +4 -1
  49. package/src/dev-broker.ts +15 -1
  50. package/src/device-login.ts +143 -0
  51. package/src/exit.ts +36 -8
  52. package/src/flag-help.ts +31 -0
  53. package/src/forge/claude-adapter.ts +17 -4
  54. package/src/forge/permission-rules.ts +158 -0
  55. package/src/forge/touches.ts +7 -6
  56. package/src/function-outside-imports.ts +13 -2
  57. package/src/function-source.ts +90 -0
  58. package/src/harness.ts +4 -4
  59. package/src/key-reach.ts +64 -0
  60. package/src/local/local-actions.ts +15 -3
  61. package/src/local/local-apply-error.ts +26 -0
  62. package/src/local/local-apps.ts +9 -3
  63. package/src/local/local-blueprint-schemas.ts +15 -1
  64. package/src/local/local-confidential.ts +81 -0
  65. package/src/local/local-data-load.ts +130 -0
  66. package/src/local/local-dev-references.ts +24 -4
  67. package/src/local/local-embedding-setup.ts +62 -0
  68. package/src/local/local-jobs.ts +108 -0
  69. package/src/local/local-keys.ts +5 -3
  70. package/src/local/local-new.ts +148 -0
  71. package/src/local/local-org-settings.ts +1 -1
  72. package/src/local/local-person-steps.ts +340 -0
  73. package/src/local/local-personas.ts +16 -5
  74. package/src/local/local-portability.ts +22 -1
  75. package/src/local/local-publish.ts +26 -0
  76. package/src/local/local-request-evidence.ts +35 -6
  77. package/src/local/local-runtime-history.ts +4 -3
  78. package/src/local/local-said-once.ts +51 -0
  79. package/src/local/local-scaffold.ts +40 -2
  80. package/src/local/local-schemas.ts +60 -11
  81. package/src/local/local-seed.ts +1 -1
  82. package/src/local/local-stand-ins.ts +44 -0
  83. package/src/local/local-studio-server.ts +13 -7
  84. package/src/local/local-supervisor.ts +22 -0
  85. package/src/local/local-sync-models.ts +5 -2
  86. package/src/local/local-sync-names.ts +3 -0
  87. package/src/local/local-sync-portable.ts +64 -15
  88. package/src/local/local-sync-precheck.ts +30 -1
  89. package/src/local/local-sync-push.ts +81 -8
  90. package/src/local/local-sync-remote.ts +10 -7
  91. package/src/local/local-test-models.ts +173 -0
  92. package/src/local/local-test-runner.ts +232 -25
  93. package/src/local/local-test-scope.ts +174 -0
  94. package/src/local/local-ungranted-reads.ts +55 -0
  95. package/src/local/local-writers.ts +171 -0
  96. package/src/main.ts +75 -17
  97. package/src/mcp/browser/ax-snapshot.ts +13 -1
  98. package/src/mcp/browser/page-scripts.ts +1 -0
  99. package/src/mcp/mcp-conversations.ts +171 -0
  100. package/src/mcp/mcp-server.ts +4 -1
  101. package/src/mcp/mcp-traces.ts +7 -2
  102. package/src/mcp/tools/agent-tools.ts +94 -2
  103. package/src/mcp/tools/project-tools.ts +149 -13
  104. package/src/mcp/tools/test-tools.ts +64 -13
  105. package/src/output.ts +22 -7
  106. package/src/pack.ts +16 -0
  107. package/src/project-context.ts +18 -5
  108. package/src/project.ts +4 -0
  109. package/src/remote-guard.ts +52 -0
  110. package/src/vendor/kit-assets.json +67 -6
@@ -16,7 +16,7 @@ export interface EvalsDetail {
16
16
  gated: number
17
17
  passed: number
18
18
  failed: number
19
- newlyFailing: Array<{ name: string; file: string; reason: string | null }>
19
+ newlyFailing: Array<{ name: string; file: string; reason: string | null; environment?: string }>
20
20
  /**
21
21
  * Cases that couldn't run (the model provider refused them, the runtime did
22
22
  * not answer): evidence with any is not attached, since none newly failing
@@ -30,6 +30,8 @@ export interface EvalsDetail {
30
30
  baselinePassRate: number | null
31
31
  efficiency: { latencyMs: number | null; toolCalls: number | null; baseline: { latencyMs: number | null; toolCalls: number | null } | null }
32
32
  judged: { ran: boolean; reason: string | null }
33
+ /** With `--repeat`: how many times each case ran, and the cases that passed some runs and failed others. */
34
+ repeat?: { runs: number; flaky: string[] }
33
35
  ranAt: string
34
36
  /** Where they ran. */
35
37
  runtime: string
@@ -46,16 +48,39 @@ const mean = (values: Array<number | undefined>): number | null => {
46
48
  * (newly failing cases), the pass rate either side, and efficiency per case,
47
49
  * which is reported and never gates.
48
50
  */
49
- export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; baseline: LastRun; runtime: string; now?: Date }): EvalsDetail {
51
+ /**
52
+ * Why a case that failed here may pass in the workspace (X2, T12 step 2): it
53
+ * called a stand-in Plugin, which answers "not connected" on the local
54
+ * runtime and is the real connection in the target. Still a failure; the
55
+ * label says where it comes from.
56
+ */
57
+ export function environmentOnly(result: Pick<CaseResult, 'toolNames'>, standIns: readonly string[]): string | null {
58
+ const used = standIns.filter((install) => (result.toolNames ?? []).some((t) => t.startsWith(`mcp__${install}__`)))
59
+ return used.length ? `local environment: ${used.join(', ')} ${used.length === 1 ? 'is a stand-in' : 'are stand-ins'} here (present in the target)` : null
60
+ }
61
+
62
+ /** The stand-in Plugins the project declares (`mcp/<install>.stand-in.json`), by install name. */
63
+ export function standInInstalls(root: string): string[] {
64
+ const dir = join(root, 'mcp')
65
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.stand-in.json')).map((f) => f.slice(0, -'.stand-in.json'.length)).sort() : []
66
+ }
67
+
68
+ export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; baseline: LastRun; runtime: string; now?: Date; standIns?: readonly string[] }): EvalsDetail {
50
69
  const { results, baseline } = input
51
70
  const compared = compareWithLastRun(results, baseline)
52
71
  const before = results.map((r) => baseline[caseKey(r)]).filter((b) => b !== undefined)
53
72
  const judgedCases = input.files.some((f) => f.cases.some((c) => c.judged?.length))
73
+ // Judged expectations are graded by the runtime's judge (X1); when it couldn't grade some, that is said.
74
+ const ungraded = results.filter((r) => r.judgedNotGraded)
75
+ const runs = Math.max(1, ...results.map((r) => r.repeat?.runs ?? 1))
54
76
  return {
55
77
  gated: results.length,
56
78
  passed: results.filter((r) => r.passed).length,
57
79
  failed: results.filter((r) => !r.passed && !r.notRun).length,
58
- newlyFailing: compared.newlyFailing.map((r) => ({ name: r.name, file: r.file, reason: r.reason ?? null })),
80
+ newlyFailing: compared.newlyFailing.map((r) => {
81
+ const environment = environmentOnly(r, input.standIns ?? [])
82
+ return { name: r.name, file: r.file, reason: r.reason ?? null, ...(environment ? { environment } : {}) }
83
+ }),
59
84
  couldNotRun: compared.couldNotRun.map((r) => ({ name: r.name, what: r.notRun!.what, fix: r.notRun!.fix })),
60
85
  fixed: compared.fixed.map((r) => r.name),
61
86
  stillFailing: compared.stillFailing.map((r) => r.name),
@@ -67,8 +92,12 @@ export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; b
67
92
  toolCalls: mean(results.map((r) => r.toolCalls)),
68
93
  baseline: before.length ? { latencyMs: mean(before.map((b) => b!.ms)), toolCalls: mean(before.map((b) => b!.toolCalls)) } : null,
69
94
  },
70
- // Judged expectations are graded by the platform's judge model; a local run grades the rest.
71
- judged: { ran: false, reason: judgedCases ? 'judged expectations are graded on the platform, with its judge model' : null },
95
+ judged: !judgedCases
96
+ ? { ran: false, reason: null }
97
+ : ungraded.length
98
+ ? { ran: false, reason: `the local runtime didn't grade ${ungraded.length} case${ungraded.length === 1 ? '’s' : 's’'} judged expectations: ${ungraded[0]!.judgedNotGraded}` }
99
+ : { ran: true, reason: null },
100
+ ...(runs > 1 ? { repeat: { runs, flaky: results.filter((r) => r.repeat?.flaky).map((r) => r.name) } } : {}),
72
101
  ranAt: (input.now ?? new Date()).toISOString(),
73
102
  runtime: input.runtime,
74
103
  }
@@ -79,7 +108,7 @@ export function evalsLine(title: string, d: EvalsDetail): string {
79
108
  if (!d.gated) return `${title}: no gated cases (mark the ones that guard it with gate: true)`
80
109
  if (d.couldNotRun.length) return `${title}: ${d.couldNotRun.length} of ${d.gated} gated case${d.gated === 1 ? '' : 's'} couldn’t run: ${[...new Set(d.couldNotRun.map((c) => c.what))].join('; ')}; ${d.couldNotRun[0]!.fix}`
81
110
  const rate = d.passRate === null ? '' : `, ${Math.round(d.passRate * 100)}% pass${d.baselinePassRate === null ? '' : ` (was ${Math.round(d.baselinePassRate * 100)}%)`}`
82
- return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => c.name).join(', ')})` : 'none newly failing'}${rate}`
111
+ return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => (c.environment ? `${c.name}: ${c.environment}` : c.name)).join(', ')})` : 'none newly failing'}${rate}`
83
112
  }
84
113
 
85
114
  /** The newest pictures a session saved (`.frontera/evidence/<session>/`), newest first, at most `limit`. */
@@ -13,12 +13,13 @@ import type { LocalProject } from './local-project'
13
13
  */
14
14
 
15
15
  export interface RuntimeRecord {
16
- event: 'started' | 'stopped' | 'failed'
16
+ /** `jobs-restarted`: the jobs engine alone was restarted (by `local restart jobs`, or the supervisor when it stopped answering). */
17
+ event: 'started' | 'stopped' | 'failed' | 'jobs-restarted'
17
18
  /** When it happened: a start when the runtime was ready, a stop when it had stopped. */
18
19
  at: string
19
20
  /** A start: when it began starting, so its duration can be said. */
20
21
  since?: string
21
- /** A failed start: which part, and why. */
22
+ /** A failed start: which part, and why. A jobs restart: why, and the runs it lost. */
22
23
  detail?: string
23
24
  }
24
25
 
@@ -32,7 +33,7 @@ export function readRuntimeHistory(stateDir: string): RuntimeRecord[] {
32
33
  try {
33
34
  const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
34
35
  return Array.isArray(list)
35
- ? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed'].includes((r as RuntimeRecord).event))
36
+ ? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed', 'jobs-restarted'].includes((r as RuntimeRecord).event))
36
37
  : []
37
38
  } catch {
38
39
  return []
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { writeFileAtomic } from '../fs-atomic'
6
+ import type { LocalProject } from './local-project'
7
+
8
+ /**
9
+ * Lists `local dev` says on every apply, said in full once per runtime start
10
+ * and as one line after that while they stay the same (Forge improvement
11
+ * plan N9): the settings that differ from the workspace, the failures the
12
+ * pull brought. In one build the same 8-line settings list and the same
13
+ * unfixable failure were printed on each of about 30 applies.
14
+ *
15
+ * Kept in `.frontera/local/said.json`: each list's digest and when it was
16
+ * last said in full.
17
+ */
18
+
19
+ type Said = Record<string, { digest: string; at: string }>
20
+
21
+ function path(project: LocalProject): string {
22
+ return join(project.stateDir, 'said.json')
23
+ }
24
+
25
+ function read(project: LocalProject): Said {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path(project), 'utf8')) as unknown
28
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Said) : {}
29
+ } catch {
30
+ return {}
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Whether to say this list in full: it changed since it was last said, or
36
+ * was last said before `since` (the runtime's start). Recorded when it is.
37
+ * Never throws: a lost record only means the list is said in full again.
38
+ */
39
+ export function sayInFull(project: LocalProject, key: string, lines: readonly string[], since: string | undefined, now = new Date()): boolean {
40
+ const digest = createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 16)
41
+ const said = read(project)
42
+ const last = said[key]
43
+ const stale = !last || last.digest !== digest || (since !== undefined && Date.parse(last.at) < Date.parse(since))
44
+ if (!stale) return false
45
+ try {
46
+ writeFileAtomic(path(project), `${JSON.stringify({ ...said, [key]: { digest, at: now.toISOString() } }, null, 2)}\n`, 0o644)
47
+ } catch {
48
+ // Said in full again next time.
49
+ }
50
+ return true
51
+ }
@@ -449,8 +449,26 @@ the cases that guard an Agent's behavior \`gate: true\`, and run them with
449
449
  you take are saved under \`.frontera/evidence/<session>/\`, where the
450
450
  person can attach them to the request.
451
451
 
452
- You cannot promote a deployment, apply a change request or read secrets.
453
- Say what is ready and let the person decide.
452
+ Check every page (an App, a widget, the website it is embedded on) with the
453
+ frontera MCP's \`browser_*\` tools, not another browser plugin: they share the
454
+ project's tabs and sign-ins, and the person watches them in Forge.
455
+
456
+ You cannot promote a deployment, apply a change request, decide for an
457
+ Action, grant or read secrets. Prepare each such step for the person
458
+ instead: the MCP tool \`person_step\` (or \`frontera local steps add\`)
459
+ records the command, why it is needed, what it changes and the credential
460
+ it needs, and Forge shows the delivery's steps as one card the person runs
461
+ from. Prepare them all at once, in order, and never ask the person to paste
462
+ a key, token or command output into the chat.
463
+
464
+ The person may write while you work. Acknowledge each such message in your
465
+ next visible text: say you have it, and whether you act on it now or after
466
+ the step you are on; never let one go unanswered. When what is left is only
467
+ the person's (steps you prepared, a decision, an answer to an open
468
+ question), stop: end with \`Blocked on you:\` and each open item with its
469
+ evidence (the step on the delivery card, what you verified and how). Do not
470
+ retry what only the person can do, and do not call the work done while
471
+ those items are open.
454
472
 
455
473
  The guides describe the product and its concepts, mostly through its
456
474
  screens. The exact format of each file is a JSON Schema for the release the
@@ -562,6 +580,8 @@ export function migrateMcpConfigs(root: string): string[] {
562
580
 
563
581
  const GITIGNORE = `node_modules/
564
582
  .env
583
+ # A separate browser plugin's output (screenshots of other sites included); the frontera browser keeps its own under .frontera/.
584
+ .playwright-mcp/
565
585
  `
566
586
 
567
587
  /**
@@ -604,6 +624,24 @@ function sampleFiles(name: string, agentModel?: string): Record<string, string>
604
624
  }
605
625
  }
606
626
 
627
+ /**
628
+ * A new Agent's files, as the sample Agent's (L8, `frontera local new
629
+ * agent`): its agent.json with the name, description and model given, its
630
+ * bindings.json and a starter case. Behavior settings (operating.json) only
631
+ * for an Agent that serves customers: an internal Agent carries none.
632
+ */
633
+ export function starterAgentFiles(slug: string, opts: { name: string; description?: string; model?: string; customer?: boolean }): Record<string, string> {
634
+ const agent = JSON.parse(AGENT('', opts.model)) as { config: Record<string, unknown> }
635
+ agent.config.name = opts.name
636
+ agent.config.description = opts.description ?? null
637
+ return {
638
+ [`agents/${slug}/agent.json`]: `${JSON.stringify(agent, null, 2)}\n`,
639
+ [`agents/${slug}/bindings.json`]: BINDINGS,
640
+ [`agents/${slug}/evals/regression.yaml`]: EVALS.replace('for the assistant', `for ${opts.name}`),
641
+ ...(opts.customer ? { [`agents/${slug}/operating.json`]: OPERATING } : {}),
642
+ }
643
+ }
644
+
607
645
  /** The sample Agent as compared: its model and the project's name in its description are set per project. */
608
646
  function sampleAgent(text: string): unknown {
609
647
  try {
@@ -33,6 +33,7 @@ export const SCHEMA_KINDS = [
33
33
  'bindings',
34
34
  'theme',
35
35
  'chat-app',
36
+ 'app',
36
37
  'knowledge',
37
38
  'skill',
38
39
  'blueprint-object-type',
@@ -58,6 +59,7 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
58
59
  bindings: 'agents/<slug>/bindings.json',
59
60
  theme: 'themes/<name>/theme.json',
60
61
  'chat-app': 'chat-apps/<slug>.json',
62
+ app: 'apps/<slug>.json',
61
63
  knowledge: 'knowledge/<name>/knowledge.json',
62
64
  skill: 'skills/<name>/SKILL.md (its front matter)',
63
65
  secrets: 'secrets.json',
@@ -76,13 +78,17 @@ type JsonSchema = Record<string, unknown>
76
78
  * One check on a case's last reply, by its `type` (`runCheck` in `local-test-runner.ts`).
77
79
  * Open to other keys: a check pulled from the platform may carry the platform's own.
78
80
  */
79
- function CHECK(type: 'contains' | 'regex' | 'tool_called' | 'tool_not_called'): JsonSchema {
81
+ /** Which turn a check reads (L3): a person turn's number from 1, `last`, `any` or `all`. */
82
+ const TURN = (what: string) => ({ anyOf: [{ type: 'integer', minimum: 1 }, { enum: ['last', 'any', 'all'] }], description: what })
83
+ const REPLY_TURN = TURN('Which reply the check reads: the last (the default), turn n (a person turn’s number from 1), `any` (one turn’s reply passes) or `all` (every turn’s passes). A turn the case didn’t reach fails the check.')
84
+
85
+ function CHECK(type: 'contains' | 'not_contains' | 'regex' | 'tool_called' | 'tool_not_called'): JsonSchema {
80
86
  const text = { type: 'string', description: 'What the check means, in words; shown when it fails.' }
81
- if (type === 'contains') {
87
+ if (type === 'contains' || type === 'not_contains') {
82
88
  return {
83
- title: 'contains',
89
+ title: type,
84
90
  type: 'object',
85
- properties: { type: { const: 'contains' }, value: { type: 'string', minLength: 1, description: 'The phrase the reply must hold, ignoring case. Text: quote a number (`"12"`).' }, text },
91
+ properties: { type: { const: type }, value: { type: 'string', minLength: 1, description: type === 'contains' ? 'The phrase the reply must hold, ignoring case. Text: quote a number (`"12"`).' : 'A phrase the reply must not hold, ignoring case.' }, text, turn: REPLY_TURN },
86
92
  required: ['type', 'value'],
87
93
  }
88
94
  }
@@ -92,9 +98,10 @@ function CHECK(type: 'contains' | 'regex' | 'tool_called' | 'tool_not_called'):
92
98
  type: 'object',
93
99
  properties: {
94
100
  type: { const: 'regex' },
95
- value: { type: 'string', minLength: 1, maxLength: 500, description: 'The pattern the reply must match (JavaScript syntax); a nested quantifier such as `(a+)+` is refused.' },
101
+ value: { type: 'string', minLength: 1, maxLength: 500, description: 'The pattern the reply must match: a JavaScript regular expression (ECMAScript, as `new RegExp(value, flags)` reads it), at most 500 characters, searched for anywhere in the first 20,000 characters of the reply. A group that is itself quantified, such as `(a+)+`, is refused, here and on the platform. Lookbehind, named groups and `\\b` work; a negative lookahead that never matches passes every reply, so test it against a passing and a failing reply.' },
96
102
  flags: { type: 'string', pattern: '^[dgimsuvy]*$', description: 'Regex flags; `i` when absent.' },
97
103
  text,
104
+ turn: REPLY_TURN,
98
105
  },
99
106
  required: ['type', 'value'],
100
107
  }
@@ -105,8 +112,8 @@ function CHECK(type: 'contains' | 'regex' | 'tool_called' | 'tool_not_called'):
105
112
  properties: {
106
113
  type: { const: type },
107
114
  tool: { type: 'string', minLength: 1, description: 'The tool’s name as the trace shows it (`function_<slug>` for a Function, `searchKnowledge`; a Plugin’s tool by its capability name, `mcp__…`, never its bare name).' },
108
- argsSubset: { type: 'object', description: 'The arguments the call must carry.' },
109
- turn: { enum: ['all', 'last'], description: 'Which turns’ calls the check reads: every turn of the case (`all`, the default) or only the last one, the turn whose reply the case grades.' },
115
+ argsSubset: { type: 'object', description: 'The arguments the call must carry. `{ absent: true }` as a value says the call must not carry that argument (`phone: { absent: true }`; an empty value counts as not there).' },
116
+ turn: TURN('Which turns’ calls the check reads: every turn of the case (`all`, the default, or `any`), only the last one (the turn whose reply the case grades), or turn n.'),
110
117
  mode: { enum: ['exact', 'ignore', 'subset', 'superset'], description: 'How argsSubset is compared; superset when absent. The `action` narration an Agent may add to any call is left out of the comparison unless argsSubset names it.' },
111
118
  text,
112
119
  },
@@ -134,7 +141,7 @@ const EVALS_SCHEMA: JsonSchema = {
134
141
  conversation: {
135
142
  type: 'array',
136
143
  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). 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.',
144
+ 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 goes to a workspace whose evaluation datasets hold conversations, whose runner sends each user turn as soon as the reply before it ends; one with `waitSeconds` runs on this computer only, and an older workspace, which holds single questions, gets only the single-question cases.',
138
145
  items: {
139
146
  type: 'object',
140
147
  properties: {
@@ -164,12 +171,18 @@ const EVALS_SCHEMA: JsonSchema = {
164
171
  items: { type: 'string', minLength: 1 },
165
172
  description: 'Phrases the last reply must contain, ignoring case. When present, expected is not checked.',
166
173
  },
174
+ notContains: {
175
+ type: 'array',
176
+ minItems: 1,
177
+ items: { type: 'string', minLength: 1 },
178
+ description: 'Phrases the last reply must not contain, ignoring case: what the Agent must never say. A check of type not_contains with a `turn` reads another reply.',
179
+ },
167
180
  checks: {
168
181
  type: 'array',
169
182
  description: 'Checks on the last reply and the tools the Agent called, graded as the platform grades them; every one must pass. `type` says which keys a check takes.',
170
- items: { oneOf: [CHECK('contains'), CHECK('regex'), CHECK('tool_called'), CHECK('tool_not_called')] },
183
+ items: { oneOf: [CHECK('contains'), CHECK('not_contains'), CHECK('regex'), CHECK('tool_called'), CHECK('tool_not_called')] },
171
184
  },
172
- judged: { type: 'array', items: { type: 'string' }, description: 'Expectations only the platform’s judge grades; kept, not graded locally.' },
185
+ judged: { type: 'array', items: { type: 'string' }, description: 'Expectations in words, graded by a judge model once the case’s other checks pass: on the platform by its judge, locally by the expectation judge on the grading model (`frontera local model-default <model> --for tests`).' },
173
186
  gold: { type: 'boolean', description: 'Human-certified on the platform.' },
174
187
  decision: {
175
188
  enum: ['approve', 'deny', 'expect'],
@@ -204,6 +217,7 @@ const EVALS_SCHEMA: JsonSchema = {
204
217
  { required: ['conversation', 'expected'] },
205
218
  { required: ['conversation', 'expectedContains'] },
206
219
  { required: ['conversation', 'checks'] },
220
+ { required: ['conversation', 'notContains'] },
207
221
  { required: ['scenario'] },
208
222
  ],
209
223
  additionalProperties: false,
@@ -308,7 +322,7 @@ const DATASET_SCHEMA: JsonSchema = {
308
322
  $schema: DRAFT,
309
323
  title: 'Dataset definition (datasets/<apiName>.json)',
310
324
  description:
311
- 'Data the Blueprint reads. kind says where the columns come from: blank declares them, file reads a CSV, source pulls a relation from a connected Source. A key another kind reads is refused.',
325
+ 'Data the Blueprint reads. kind says where the columns come from: blank declares them, file reads a CSV, source pulls a relation from a connected Source. A key another kind reads is refused. A change request carries a dataset as its columns, never its rows: a blank one, or a file one whose `columns` give every included column its databaseType and nullable.',
312
326
  type: 'object',
313
327
  oneOf: [
314
328
  {
@@ -723,6 +737,40 @@ const SECRETS_SCHEMA: JsonSchema = {
723
737
  additionalProperties: false,
724
738
  }
725
739
 
740
+ /** `apps/<slug>.json`, as a pull writes it (`appDocument` in `local-apps.ts`; `appSettingsCandidate` in the service). */
741
+ const APP_SCHEMA: JsonSchema = {
742
+ $schema: DRAFT,
743
+ title: 'Custom App (apps/<slug>.json)',
744
+ description:
745
+ 'What a Custom App is and the Functions it may start. Its code is its own project in apps/<slug>/, deployed with `frontera app deploy`; who may open it and its addresses are set on the platform. `frontera local dev` saves the App by its file name (the slug) and binds the Functions listed; `frontera local sync push` proposes its name, description, icon and Functions, and a Function bound in the workspace that the file no longer lists is unbound when the request is applied. `hosting`, `version`, `routing` and the domains are what the workspace reports, read only here.',
746
+ type: 'object',
747
+ properties: {
748
+ name: { type: 'string', minLength: 1, maxLength: 120, description: 'The App’s name; the file name when absent.' },
749
+ description: { type: 'string', maxLength: 500 },
750
+ icon: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,119}$', description: 'An icon from the platform’s set, by name (`chart-bar`); the App’s card shows its first letter when the name is not one. Left out, the App keeps its icon.' },
751
+ functions: {
752
+ type: 'array',
753
+ description: 'Every Function the App may start, through a Function bound to it; the App holds no secrets.',
754
+ items: {
755
+ type: 'object',
756
+ properties: {
757
+ function: { type: 'string', pattern: refPattern(['function']), description: '`function/<slug>`: functions/<slug>.ts, or one the workspace has.' },
758
+ enabled: { type: 'boolean', default: true, description: 'false keeps the binding switched off.' },
759
+ },
760
+ required: ['function'],
761
+ additionalProperties: false,
762
+ },
763
+ },
764
+ hosting: { enum: ['external'], description: 'Read only: hosted on its own domain.' },
765
+ version: { type: 'string', description: 'Read only: the deployment production serves.' },
766
+ routing: { type: 'string', description: 'Read only.' },
767
+ connectDomains: { type: 'array', items: { type: 'string' }, description: 'Read only: set in the App’s frontera.config.json.' },
768
+ resourceDomains: { type: 'array', items: { type: 'string' }, description: 'Read only: set in the App’s frontera.config.json.' },
769
+ },
770
+ required: ['name'],
771
+ additionalProperties: false,
772
+ }
773
+
726
774
  /** The schema this CLI owns for a kind, or null when the service owns it. */
727
775
  export function cliSchema(kind: SchemaKind): JsonSchema | null {
728
776
  if (kind === 'evals') return EVALS_SCHEMA
@@ -731,6 +779,7 @@ export function cliSchema(kind: SchemaKind): JsonSchema | null {
731
779
  if (kind === 'bindings') return BINDINGS_SCHEMA
732
780
  if (kind === 'theme') return THEME_SCHEMA
733
781
  if (kind === 'chat-app') return CHAT_APP_SCHEMA
782
+ if (kind === 'app') return APP_SCHEMA
734
783
  if (kind === 'knowledge') return KNOWLEDGE_SCHEMA
735
784
  if (kind === 'skill') return SKILL_SCHEMA
736
785
  if (kind === 'secrets') return SECRETS_SCHEMA
@@ -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 }> = [
@@ -225,6 +225,50 @@ export async function writeStandIns(root: string, names: string[], ws?: SyncClie
225
225
  return out
226
226
  }
227
227
 
228
+ /**
229
+ * The Plugin installs a Function's deploy was refused for: its `grants`
230
+ * name `plugin:<install>:<capability>` and the runtime has no install of
231
+ * that name (`grant "plugin:crm:createLead" names no plugin install called
232
+ * "crm" in this workspace`). Read from the deploy's error.
233
+ */
234
+ export function missingGrantInstalls(output: string): Array<{ install: string; capability: string }> {
235
+ const out: Array<{ install: string; capability: string }> = []
236
+ for (const m of output.matchAll(/grant "plugin:([^:"\s]+):([^"\s]+)" names no plugin install called "([^"]+)"/g)) {
237
+ if (!out.some((g) => g.install === m[1] && g.capability === m[2])) out.push({ install: m[1]!, capability: m[2]! })
238
+ }
239
+ return out
240
+ }
241
+
242
+ /**
243
+ * A stand-in for a workspace Plugin a Function grants itself (Forge
244
+ * improvement plan X2; P3 F17, P4 2.7: a pulled Function's grant named an
245
+ * install the local runtime lacks, so it failed on every apply). Written as
246
+ * `mcp/<install>.stand-in.json` with the granted capability among its tools,
247
+ * like the stand-ins a pull writes for built-in connections: the Function
248
+ * deploys here, and each call it makes says it is not connected. Never one
249
+ * where the project has the Plugin's own file. A stand-in already there
250
+ * gains the capability. Returns whether the file changed.
251
+ */
252
+ export function writeGrantStandIn(root: string, install: string, capabilities: string[]): boolean {
253
+ if (existsSync(join(root, 'mcp', `${install}.plugin.json`))) return false
254
+ const before = readStandIn(root, install)
255
+ const tools = new Map((before?.tools ?? []).map((t) => [t.name, t]))
256
+ for (const c of capabilities) if (!tools.has(c)) tools.set(c, { name: c, description: `Granted to a Function of this project; answers “not connected” here` })
257
+ const doc: StandInFile = {
258
+ standIn: true,
259
+ connection: before?.connection ?? install,
260
+ ...(before?.serves ? { serves: before.serves } : {}),
261
+ note: before?.note ?? NOTE,
262
+ tools: [...tools.values()].sort((a, b) => a.name.localeCompare(b.name)),
263
+ }
264
+ const text = `${JSON.stringify(doc, null, 2)}\n`
265
+ const path = join(root, standInPath(install))
266
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) return false
267
+ mkdirSync(dirname(path), { recursive: true })
268
+ writeFileSync(path, text)
269
+ return true
270
+ }
271
+
228
272
  /**
229
273
  * The tools a stand-in lists: the connection's, as the workspace listed
230
274
  * them, and every tool the project's Agents and workflows name on it (a
@@ -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')
@@ -37,6 +37,8 @@ import { localEmbeddingsReachable, providerEnv } from './local-keys'
37
37
  import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
38
38
  import type { PortBlock } from './local-ports'
39
39
  import { startProcess, stopProcess, waitForHttp } from './local-process'
40
+ import { restartJobs, watchJobs } from './local-jobs'
41
+ import { portAnswers } from './local-component-health'
40
42
  import type { LocalProject } from './local-project'
41
43
  import { startLocalEmbeddings } from './local-embeddings'
42
44
  import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
@@ -299,6 +301,26 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
299
301
  set(name, { status: 'failed', pid: undefined, detail: `exited with code ${code ?? 'signal'}; see \`frontera local logs ${name}\`` })
300
302
  })
301
303
  }
304
+
305
+ // Watch the jobs engine: one that stops answering is restarted alone, and the runs it lost failed (X2).
306
+ if (enabled('jobs')) {
307
+ watchJobs({
308
+ probe: () => portAnswers(ports.jobs, 3_000),
309
+ restart: async () => {
310
+ if (stopping) return
311
+ const restarted = await restartJobs(project, state, 'it stopped answering, so the supervisor restarted it')
312
+ set('jobs', { status: 'running', startedAt: new Date().toISOString(), detail: undefined })
313
+ appendFileSync(logPath(project, 'studio'), `[supervisor] jobs engine restarted in ${restarted.ms} ms${restarted.lost ? `; ${restarted.lost} lost runs marked failed` : ''}${restarted.note ? `; ${restarted.note}` : ''}\n`)
314
+ },
315
+ log: (line) => {
316
+ try {
317
+ appendFileSync(logPath(project, 'studio'), `[supervisor] ${line}\n`)
318
+ } catch {
319
+ // Logging never fails the watch.
320
+ }
321
+ },
322
+ })
323
+ }
302
324
  }
303
325
 
304
326
  type SetFn = (name: ComponentName, patch: Partial<ComponentState>) => void
@@ -63,6 +63,8 @@ export interface ProjectModels {
63
63
  workspace: Set<string> | null
64
64
  /** The workspace's models by slug, for words. */
65
65
  names: Record<string, string>
66
+ /** The workspace's evaluation datasets hold conversations of several turns (its `evaluation-conversations` capability, L3). */
67
+ conversations?: boolean
66
68
  }
67
69
 
68
70
  const cached = new Map<string, { stamp: string; value: ProjectModels }>()
@@ -84,10 +86,11 @@ export function projectModels(root: string): ProjectModels {
84
86
  const stamp = stampOf(...files)
85
87
  const known = cached.get(root)
86
88
  if (known && known.stamp === stamp) return known.value
87
- const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> } }>(join(root, REMOTE_JSON))
89
+ const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> }; capabilities?: string[] }>(join(root, REMOTE_JSON))
88
90
  const theirs = remote?.models ?? Object.keys(remote?.names?.model ?? {}).map((slug) => ({ slug }) as CatalogEntry)
89
91
  const ours = readJson<{ models?: CatalogEntry[] }>(join(root, LOCAL_CATALOG))?.models ?? []
90
- const value = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
92
+ const matched = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
93
+ const value = remote?.capabilities?.includes('evaluation-conversations') ? { ...matched, conversations: true } : matched
91
94
  cached.set(root, { stamp, value })
92
95
  return value
93
96
  }
@@ -114,6 +114,7 @@ export function namingPaths(kind: string, name: string): string[] {
114
114
  if (kind === 'chat-app') return [`chat-apps/${name}.json`]
115
115
  if (kind === 'app') return [`apps/${name}.json`]
116
116
  if (kind === 'knowledge') return [`knowledge/${name}/knowledge.json`]
117
+ if (kind === 'dataset') return [`datasets/${name}.json`]
117
118
  return []
118
119
  }
119
120
 
@@ -139,6 +140,8 @@ export function itemTitle(kind: string, name: string, files: Record<string, stri
139
140
  const own = yamlField(file, 'displayName') ?? declared(parseJson(file), 'displayName')
140
141
  return own ?? (dir === 'object-sets' ? readableName(apiName ?? name) : (apiName ?? name))
141
142
  }
143
+ // A dataset by its display name, else its API name as object types name it.
144
+ if (kind === 'dataset') return declared(parseJson(files[`datasets/${name}.json`]), 'displayName') ?? name
142
145
  if (kind === 'skill') {
143
146
  const own = yamlField(files[`skills/${name}/SKILL.md`], 'displayName')
144
147
  if (own) return own