@frontera-sdk/cli 1.50.12 → 1.50.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.12",
3
+ "version": "1.50.13",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.50.12",
43
- "@frontera-sdk/core": "1.50.12",
42
+ "@frontera-sdk/functions": "1.50.13",
43
+ "@frontera-sdk/core": "1.50.13",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.50.12",
49
+ "@frontera-sdk/forge-contracts": "1.50.13",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -0,0 +1,85 @@
1
+ import type { PlatformApi } from '../../api/platform-api'
2
+
3
+ /**
4
+ * Which sections of an agent's draft differ from what is live.
5
+ *
6
+ * Shared by `frontera agent diff` and by the Forge daemon, which records the
7
+ * answer on the touch a successful `agent apply` produced so the Preview pane
8
+ * can open the agent page on the section that changed rather than at the
9
+ * top. One implementation, so the CLI and the pane never disagree about what
10
+ * "changed" means.
11
+ */
12
+
13
+ /** Server-managed bookkeeping a draft carries but never "changes". */
14
+ const MANAGED = new Set([
15
+ 'id', 'orgId', 'workspaceId', 'createdAt', 'updatedAt', 'createdBy',
16
+ 'currentVersion', 'currentVersionId', 'draft', 'lifecycleStatus',
17
+ ])
18
+
19
+ export type AgentDraftChanges =
20
+ | { hasDraft: false; published: boolean; changed: [] }
21
+ | { hasDraft: true; published: boolean; revision: number; changed: string[] }
22
+
23
+ /**
24
+ * JSON with object keys in a fixed order, for comparison.
25
+ *
26
+ * `JSON.stringify` preserves insertion order, and the draft snapshot and the
27
+ * live snapshot are built by different code paths — so the same
28
+ * `caseDefinition` serialises as `{metrics, objectSets, objectTypes}` on one
29
+ * side and `{objectTypes, metrics, objectSets}` on the other. Comparing the
30
+ * strings reported both as changed on a draft that had touched neither, which
31
+ * is the noise `agent diff` exists to avoid.
32
+ *
33
+ * Arrays keep their order: element order is meaningful in `stages` and
34
+ * `prompts`, where a reordering IS the change.
35
+ */
36
+ export function stableStringify(value: unknown): string {
37
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
38
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
39
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
40
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
41
+ }
42
+
43
+ export async function agentDraftChanges(api: PlatformApi, id: string): Promise<AgentDraftChanges> {
44
+ // Both, before either branch answers: "the live version is current" is as
45
+ // wrong for an agent that has never published as "matches the live
46
+ // version" is, and that branch used to answer without ever looking.
47
+ const [draft, live] = await Promise.all([
48
+ api.agentDraft(id),
49
+ api.agent(id) as Promise<Record<string, unknown>>,
50
+ ])
51
+ const published = Boolean(live.currentVersion ?? live.currentVersionId)
52
+ if (!draft) return { hasDraft: false, published, changed: [] }
53
+
54
+ const liveConfig = (live.config ?? live) as Record<string, unknown>
55
+ const draftConfig = (draft.snapshot.config ?? {}) as Record<string, unknown>
56
+
57
+ /**
58
+ * A snapshot is `config` PLUS fifteen sibling sections. Only what the DRAFT
59
+ * declares is compared, never server-managed bookkeeping — taking the union
60
+ * reported `createdAt` as a pending change on a draft that touched nothing
61
+ * but a prompt. The live side of the sections comes from the live SNAPSHOT:
62
+ * `GET /config/agents/:id` returns flat scalars with every relation null, so
63
+ * every section would read as changed against it.
64
+ */
65
+ const liveSnapshot = await api.agentComposition(id).catch(() => null)
66
+ const draftSections = Object.fromEntries(
67
+ Object.entries(draft.snapshot).filter(([k]) => k !== 'config' && !MANAGED.has(k)),
68
+ )
69
+
70
+ const changed = [
71
+ ...Object.keys(draftConfig)
72
+ .filter((k) => !MANAGED.has(k))
73
+ .filter((k) => stableStringify(draftConfig[k]) !== stableStringify(liveConfig[k])),
74
+ // Only when the live snapshot could be read. Reporting every section as
75
+ // changed because one request failed would be worse than the silence
76
+ // this replaces.
77
+ ...(liveSnapshot
78
+ ? Object.keys(draftSections).filter(
79
+ (k) => stableStringify(draftSections[k]) !== stableStringify(liveSnapshot[k]),
80
+ )
81
+ : []),
82
+ ].sort()
83
+
84
+ return { hasDraft: true, published, revision: draft.revision, changed }
85
+ }
@@ -6,6 +6,7 @@ import { table } from '../../table'
6
6
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
7
7
  import { resolveWorkspaceId } from '../workspace-id'
8
8
  import { renderComposition, type Lookups } from './compose'
9
+ import { agentDraftChanges } from './draft-changes'
9
10
  import { resolveAgentRef, type AgentRow } from './resolve'
10
11
 
11
12
  function api(ctx: CommandContext): PlatformApi {
@@ -90,25 +91,7 @@ async function readDocument(ctx: CommandContext): Promise<Record<string, unknown
90
91
  }
91
92
  }
92
93
 
93
- /**
94
- * JSON with object keys in a fixed order, for comparison.
95
- *
96
- * `JSON.stringify` preserves insertion order, and the draft snapshot and the
97
- * live snapshot are built by different code paths — so the same
98
- * `caseDefinition` serialises as `{metrics, objectSets, objectTypes}` on one
99
- * side and `{objectTypes, metrics, objectSets}` on the other. Comparing the
100
- * strings reported both as changed on a draft that had touched neither, which
101
- * is the noise `agent diff` exists to avoid.
102
- *
103
- * Arrays keep their order: element order is meaningful in `stages` and
104
- * `prompts`, where a reordering IS the change.
105
- */
106
- export function stableStringify(value: unknown): string {
107
- if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
108
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
109
- const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
110
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
111
- }
94
+ export { stableStringify } from './draft-changes'
112
95
 
113
96
  /** `agent_configs.agentId` — a slug, lowercase, the value every verb takes. */
114
97
  const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
@@ -371,13 +354,10 @@ const diff: Command = {
371
354
  // Both, before either branch answers: "the live version is current" is as
372
355
  // wrong for an agent that has never published as "matches the live
373
356
  // version" is, and that branch used to answer without ever looking.
374
- const [draft, live] = await Promise.all([
375
- t.api.agentDraft(t.id),
376
- t.api.agent(t.id) as Promise<Record<string, unknown>>,
377
- ])
378
- const published = Boolean(live.currentVersion ?? live.currentVersionId)
357
+ const outcome = await agentDraftChanges(t.api, t.id)
358
+ const { published, changed } = outcome
379
359
 
380
- if (!draft) {
360
+ if (!outcome.hasDraft) {
381
361
  return {
382
362
  data: { hasDraft: false, published, changed: [] },
383
363
  text: published
@@ -387,60 +367,16 @@ const diff: Command = {
387
367
  + ` Stage one with \`frontera agent apply ${t.ref} --file <path>\`.`,
388
368
  }
389
369
  }
390
-
391
- const liveConfig = (live.config ?? live) as Record<string, unknown>
392
- const draftConfig = (draft.snapshot.config ?? {}) as Record<string, unknown>
393
-
394
- // Only what the DRAFT declares, and never server-managed bookkeeping.
395
- // Taking the union instead reported `createdAt`, `createdBy` and
396
- // `currentVersion` as pending changes on a draft that touched none of
397
- // them — noise that buries the one field the caller actually staged.
398
- const MANAGED = new Set([
399
- 'id', 'orgId', 'workspaceId', 'createdAt', 'updatedAt', 'createdBy',
400
- 'currentVersion', 'currentVersionId', 'draft', 'lifecycleStatus',
401
- ])
402
-
403
- /**
404
- * A snapshot is `config` PLUS fifteen sibling sections, and this compared
405
- * only `config`.
406
- *
407
- * So a draft that staged a capability, a skill, a prompt or a knowledge
408
- * attachment reported "matches the live version" — the answer a caller
409
- * gets when nothing is pending, given immediately after they staged
410
- * something. `capability grant` made this reachable in one step, but
411
- * `agent apply` has always been able to stage these sections too.
412
- *
413
- * The live side comes from the live SNAPSHOT rather than the config row:
414
- * `GET /config/agents/:id` returns flat scalars with every relation null,
415
- * so every section would read as "changed" against it.
416
- */
417
- const liveSnapshot = await t.api.agentComposition(t.id).catch(() => null)
418
- const draftSections = Object.fromEntries(
419
- Object.entries(draft.snapshot).filter(([k]) => k !== 'config' && !MANAGED.has(k)),
420
- )
421
-
422
- const changed = [
423
- ...Object.keys(draftConfig)
424
- .filter((k) => !MANAGED.has(k))
425
- .filter((k) => stableStringify(draftConfig[k]) !== stableStringify(liveConfig[k])),
426
- // Only when the live snapshot could be read. Reporting every section as
427
- // changed because one request failed would be worse than the silence
428
- // this replaces.
429
- ...(liveSnapshot
430
- ? Object.keys(draftSections).filter(
431
- (k) => stableStringify(draftSections[k]) !== stableStringify(liveSnapshot[k]),
432
- )
433
- : []),
434
- ].sort()
370
+ const revision = outcome.revision
435
371
 
436
372
  return {
437
- data: { hasDraft: true, revision: draft.revision, published, changed },
373
+ data: { hasDraft: true, revision, published, changed },
438
374
  text:
439
375
  changed.length > 0
440
- ? [`Draft revision ${draft.revision} differs in:`, ...changed.map((k) => ` ${k}`)].join('\n')
376
+ ? [`Draft revision ${revision} differs in:`, ...changed.map((k) => ` ${k}`)].join('\n')
441
377
  : published
442
- ? `Draft revision ${draft.revision} matches the live version.`
443
- : `Draft revision ${draft.revision}. Nothing is published yet, so there is `
378
+ ? `Draft revision ${revision} matches the live version.`
379
+ : `Draft revision ${revision}. Nothing is published yet, so there is `
444
380
  + 'nothing to compare against — the draft IS the whole configuration.\n'
445
381
  + ` Publish it with \`frontera agent publish ${t.ref}\`.`,
446
382
  }
@@ -35,6 +35,14 @@ interface Host {
35
35
  bin: string
36
36
  /** Verbs differ: Codex `plugin add`, Claude `plugin install`. */
37
37
  install: string[]
38
+ /**
39
+ * Run after `install` when the host has one. `claude plugin install` is a
40
+ * no-op on a plugin that is already installed — it does not upgrade — so a
41
+ * kit newer than what the host holds stayed stale forever, and readiness
42
+ * kept asking for a setup that changed nothing. `plugin update` is the
43
+ * upgrade; it is a no-op when already current.
44
+ */
45
+ update?: string[]
38
46
  marketplaceList: string[]
39
47
  installHint: string
40
48
  }
@@ -53,6 +61,7 @@ const HOSTS: Record<HostId, Host> = {
53
61
  label: 'Claude Code',
54
62
  bin: 'claude',
55
63
  install: ['plugin', 'install', 'frontera@frontera'],
64
+ update: ['plugin', 'update', 'frontera@frontera'],
56
65
  marketplaceList: ['plugin', 'marketplace', 'list'],
57
66
  installHint: 'install Claude Code first — https://claude.com/claude-code',
58
67
  },
@@ -207,12 +216,31 @@ export const setupCommand: Command = {
207
216
  // upgrade. Not every host has this verb; a failure here is not fatal.
208
217
  run(host.bin, ['plugin', 'marketplace', 'update', 'frontera'])
209
218
 
210
- const installed = run(host.bin, host.install)
219
+ let installed = run(host.bin, host.install)
220
+ // Installed is not upgraded: bring an already-present plugin up to the
221
+ // kit this CLI carries. An update that fails is reported as the failure
222
+ // it is — readiness would otherwise keep reading the plugin as stale and
223
+ // keep asking for a setup that "succeeded".
224
+ if (installed.ok && host.update) {
225
+ const updated = run(host.bin, host.update)
226
+ if (!updated.ok) installed = { ...updated, out: `plugin update failed: ${updated.out}` }
227
+ }
228
+ // Codex 0.125 dropped `plugin add` (and `plugin list`): plugins are
229
+ // enabled in its own settings, which this command does not edit. The
230
+ // marketplace IS registered by now, so say what is left to do rather
231
+ // than echo the host's usage text.
232
+ const noVerb = !installed.ok && /unrecognized subcommand/i.test(installed.out)
211
233
  results.push({
212
234
  host: id,
213
235
  installed: installed.ok,
214
236
  marketplace: root,
215
- ...(installed.ok ? {} : { error: installed.out }),
237
+ ...(installed.ok
238
+ ? {}
239
+ : {
240
+ error: noVerb
241
+ ? `${host.label} has no \`${host.install.slice(0, 2).join(' ')}\` — marketplace \`frontera\` is registered; enable the \`frontera\` plugin in ${host.label}'s own plugin settings`
242
+ : installed.out,
243
+ }),
216
244
  })
217
245
  }
218
246
 
@@ -120,7 +120,7 @@ export class ClaudeAdapter implements HostAdapter {
120
120
  // Under bypassPermissions the SDK never consults the callback and warns
121
121
  // loudly that it was given one.
122
122
  ...(mode === 'bypassPermissions' ? {} : { canUseTool }),
123
- env: forgeEnv(),
123
+ env: forgeEnv(ctx.project.profile ? { FRONTERA_PROFILE: ctx.project.profile } : {}),
124
124
  ...(await this.mcpServers(ctx)),
125
125
  }
126
126
 
@@ -182,8 +182,10 @@ export class ClaudeAdapter implements HostAdapter {
182
182
  */
183
183
  private async mcpServers(ctx: TurnContext): Promise<Pick<Options, 'mcpServers'>> {
184
184
  try {
185
+ // The project's bound profile outranks the directory walk, the same
186
+ // way `FRONTERA_PROFILE` does for the commands the host will run.
185
187
  const credential = await resolveCredential(
186
- ctx.apiUrl ? { apiUrl: ctx.apiUrl } : {},
188
+ ctx.project.profile ? { profile: ctx.project.profile } : ctx.apiUrl ? { apiUrl: ctx.apiUrl } : {},
187
189
  { cwd: ctx.project.root },
188
190
  )
189
191
  return {
@@ -8,7 +8,7 @@ import type { ForgeChangeStatus, ForgeChangedFile, ForgeChanges, ForgeFileEntry
8
8
  import { findProjectRoot } from '../context'
9
9
  import { readProject } from '../project'
10
10
  import { applyClaudeImport, applyManagedBlock, type BlockMarkers } from '../kit'
11
- import { ensureGitignored, findContext, writeContext } from '../project-context'
11
+ import { ensureGitignored, findContext, readContextAt, writeContext } from '../project-context'
12
12
  import { listProfiles, normalizeOrigin, type ProfileMetadata } from '../profiles'
13
13
  import { badRequest } from './http'
14
14
  import type { NewProject } from './store'
@@ -221,7 +221,6 @@ export function workspaceIdInFolder(root: string): string | null {
221
221
  }
222
222
  }
223
223
 
224
- /** The profile bound to this deployment's API origin, if the CLI has one. */
225
224
  /**
226
225
  * The profile to bind a workspace folder to.
227
226
  *
@@ -283,20 +282,43 @@ export function ensureWorkspaceProject(
283
282
  if (!isGitWorkTree(root)) {
284
283
  spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' })
285
284
  }
285
+ let profiles: Array<{ name: string; profile: ProfileMetadata }> = []
286
+ try {
287
+ profiles = (deps.profiles ?? listProfiles)()
288
+ } catch (error) {
289
+ // `listProfiles` is loud about a corrupt profiles file on purpose; the
290
+ // daemon must not die for it, but must not hide it either.
291
+ warn(`could not read CLI profiles: ${error instanceof Error ? error.message : String(error)}`)
292
+ }
293
+
294
+ // This folder's OWN binding first. An ancestor's counts only when it is
295
+ // this workspace's own key: walking up used to find `~/.frontera/context.json`
296
+ // for every folder under the home directory, so a managed folder was never
297
+ // bound and resolved, at run time, to whatever the person had bound their
298
+ // home to — an organization key `agent list` refuses. A deliberate parent
299
+ // binding to the right workspace key is honoured; anything else is
300
+ // overridden here, and the log says so.
286
301
  let bound: string | null = null
287
302
  try {
288
- bound = findContext(root)?.profile ?? null
303
+ bound = readContextAt(root, process.env)?.profile ?? null
289
304
  } catch {
290
305
  bound = null
291
306
  }
307
+ let inherited: string | null = null
292
308
  if (!bound) {
293
- let profiles: Array<{ name: string; profile: ProfileMetadata }> = []
294
309
  try {
295
- profiles = (deps.profiles ?? listProfiles)()
296
- } catch (error) {
297
- warn(`could not read CLI profiles: ${error instanceof Error ? error.message : String(error)}`)
310
+ inherited = findContext(root)?.profile ?? null
311
+ } catch {
312
+ inherited = null
298
313
  }
314
+ const inheritedMeta = inherited ? profiles.find((p) => p.name === inherited)?.profile : undefined
315
+ if (inheritedMeta?.workspaceId === input.workspaceId) bound = inherited
316
+ }
317
+ if (!bound) {
299
318
  const profile = profileForWorkspace(apiUrl, { workspaceId: input.workspaceId, ...(input.orgId ? { orgId: input.orgId } : {}) }, profiles)
319
+ if (profile && inherited && inherited !== profile) {
320
+ warn(`${root}: binding to \`${profile}\` (this workspace's key); an ancestor binds \`${inherited}\`, which is not`)
321
+ }
300
322
  if (!profile) {
301
323
  warn(`no CLI profile for this workspace or organization at ${apiUrl ?? '(no api url)'}; ${root} left unbound`)
302
324
  } else {
package/src/forge/runs.ts CHANGED
@@ -6,6 +6,8 @@ import { readUIMessageStream, type UIMessage, type UIMessageChunk } from 'ai'
6
6
  import type { AdapterFactory, ApprovalDecision, HostAdapter } from './adapter'
7
7
  import type { Logger } from './log'
8
8
  import type { ForgeStore, StoredThread } from './store'
9
+ import { enrichTouch, touchDelta, type TouchEnricher } from './touch-details'
10
+ import { TouchTracker } from './touches'
9
11
 
10
12
  /**
11
13
  * Live turns. Each run keeps an append-only chunk journal in memory and fans
@@ -82,6 +84,8 @@ export class RunManager {
82
84
  private readonly adapters: AdapterFactory,
83
85
  private readonly log: Logger,
84
86
  private readonly apiUrl: string,
87
+ /** Injectable so a test can script what a touch turns out to have changed. */
88
+ private readonly enrich: TouchEnricher = enrichTouch,
85
89
  ) {}
86
90
 
87
91
  activeRunFor(threadId: string): string | null {
@@ -186,10 +190,49 @@ export class RunManager {
186
190
  }
187
191
 
188
192
  private async execute(run: LiveRun, thread: StoredThread, project: ForgeProject, text: string): Promise<void> {
193
+ // Watches the same chunks the client sees for `frontera` commands that
194
+ // succeeded, and writes each as a touch as it happens — the pane polls
195
+ // the store for them, and a reload could not rebuild them from messages.
196
+ const touches = new TouchTracker()
197
+ // Enrichments run one after another: two touches from one command line
198
+ // (`apply a && capability grant a x`) must not race, or the later one's
199
+ // delta is computed before the earlier has its details and blames the
200
+ // whole pending draft on itself.
201
+ let enrichments: Promise<void> = Promise.resolve()
189
202
  const emit = (chunk: UIMessageChunk) => {
190
203
  if (run.finished) return
191
204
  run.journal.push(chunk)
192
205
  for (const sub of run.subscribers) sub.push(chunk)
206
+ for (const touch of touches.observe(chunk)) {
207
+ try {
208
+ if (this.store.getThread(thread.id)) {
209
+ const stored = this.store.insertTouch({ threadId: thread.id, runId: run.id, ...touch })
210
+ // Off the emit path: the answer needs the platform, and the turn
211
+ // must not wait for it. Nothing here can fail the run.
212
+ enrichments = enrichments.then(() => this.enrich(touch, project))
213
+ .then((details) => {
214
+ if (!details) return
215
+ // Against the last enriched touch on the same agent before
216
+ // this one, so the pane points at what THIS touch changed,
217
+ // not at the whole pending draft.
218
+ const previous = this.store.previousEnrichedAgentTouch(thread.id, stored.id, {
219
+ ref: touch.ref,
220
+ ...(details.label ? { label: details.label } : {}),
221
+ })
222
+ this.store.updateTouchDetails(stored.id, {
223
+ ...details,
224
+ delta: touchDelta(details.changed, previous?.details),
225
+ })
226
+ })
227
+ .catch((err) => {
228
+ this.log.warn(`run ${run.id}: touch ${stored.id} not enriched: ${err instanceof Error ? err.message : String(err)}`)
229
+ })
230
+ }
231
+ } catch (err) {
232
+ // A lost touch degrades the pane, never the turn.
233
+ this.log.warn(`run ${run.id}: touch not recorded: ${err instanceof Error ? err.message : String(err)}`)
234
+ }
235
+ }
193
236
  }
194
237
 
195
238
  let outcome: { error: string | null }
@@ -31,6 +31,7 @@ import { skillsFor } from './claude-skills'
31
31
  import { normalizeTurnText } from './turn-text'
32
32
  import { ensureWorkspaceProject, inspectRoot, listFiles, readChanges, resolveWorkspaceRoot, workspaceIdInFolder } from './projects'
33
33
  import { RunManager } from './runs'
34
+ import type { TouchEnricher } from './touch-details'
34
35
  import { sessionView } from './sessions'
35
36
  import { ForgeStore, type StoredThread, type ThreadPatch } from './store'
36
37
  import { TerminalManager, type TerminalClient } from './terminals'
@@ -56,6 +57,8 @@ export interface ForgeServerOptions {
56
57
  storePath: string
57
58
  log: Logger
58
59
  adapters?: AdapterFactory
60
+ /** How a recorded touch learns what it changed. A test scripts it. */
61
+ touchEnricher?: TouchEnricher
59
62
  /**
60
63
  * Where managed workspace folders are created. Defaults to `~/Frontera`; a
61
64
  * test points it at a scratch directory so it never touches the real one.
@@ -193,7 +196,7 @@ export function startForgeServer(opts: ForgeServerOptions): ForgeServer {
193
196
  const store = new ForgeStore(opts.storePath)
194
197
  const reaped = store.reapOrphanRuns()
195
198
  if (reaped > 0) log.warn(`${reaped} run(s) from a previous daemon marked interrupted`)
196
- const runs = new RunManager(store, opts.adapters ?? defaultAdapters(log), log, opts.apiUrl)
199
+ const runs = new RunManager(store, opts.adapters ?? defaultAdapters(log), log, opts.apiUrl, opts.touchEnricher)
197
200
  const terminals = new TerminalManager(log)
198
201
 
199
202
  const corsHeaders = (): Record<string, string> =>
@@ -393,6 +396,12 @@ export function startForgeServer(opts: ForgeServerOptions): ForgeServer {
393
396
  }
394
397
  route('DELETE', '/v1/forge/threads/:id', (_req, params) => deleteThread(params.id!))
395
398
 
399
+ // What the session changed on the platform, for the preview pane to follow.
400
+ route('GET', '/v1/forge/threads/:id/touches', (_req, params) => {
401
+ requireThread(params.id!)
402
+ return ok(store.listTouches(params.id!))
403
+ })
404
+
396
405
  // -- terminals ------------------------------------------------------------
397
406
 
398
407
  // `?threadId=` is how the surface lists one session's tabs; unfiltered is
@@ -2,7 +2,15 @@ import { Database } from 'bun:sqlite'
2
2
  import { mkdirSync } from 'node:fs'
3
3
  import { dirname } from 'node:path'
4
4
 
5
- import type { ForgeHostId, ForgeProject, ForgeThread, ForgeThreadStatus } from '@frontera-sdk/forge-contracts'
5
+ import type {
6
+ ForgeArtifactKind,
7
+ ForgeArtifactTouch,
8
+ ForgeArtifactTouchDetails,
9
+ ForgeHostId,
10
+ ForgeProject,
11
+ ForgeThread,
12
+ ForgeThreadStatus,
13
+ } from '@frontera-sdk/forge-contracts'
6
14
  import type { UIMessage } from 'ai'
7
15
 
8
16
  /**
@@ -57,6 +65,19 @@ CREATE TABLE IF NOT EXISTS runs (
57
65
  finished_at TEXT,
58
66
  error TEXT
59
67
  );
68
+ CREATE TABLE IF NOT EXISTS touches (
69
+ id TEXT PRIMARY KEY,
70
+ thread_id TEXT NOT NULL,
71
+ run_id TEXT NOT NULL,
72
+ seq INTEGER NOT NULL,
73
+ kind TEXT NOT NULL,
74
+ verb TEXT NOT NULL,
75
+ ref TEXT,
76
+ live INTEGER NOT NULL DEFAULT 0,
77
+ at TEXT NOT NULL,
78
+ details TEXT
79
+ );
80
+ CREATE INDEX IF NOT EXISTS touches_thread ON touches(thread_id, seq);
60
81
  `
61
82
 
62
83
  interface ProjectRow {
@@ -104,6 +125,28 @@ interface RunRow {
104
125
  error: string | null
105
126
  }
106
127
 
128
+ interface TouchRow {
129
+ id: string
130
+ thread_id: string
131
+ run_id: string
132
+ seq: number
133
+ kind: string
134
+ verb: string
135
+ ref: string | null
136
+ live: number
137
+ at: string
138
+ details: string | null
139
+ }
140
+
141
+ export interface NewTouch {
142
+ threadId: string
143
+ runId: string
144
+ kind: ForgeArtifactKind
145
+ verb: string
146
+ ref: string | null
147
+ live: boolean
148
+ }
149
+
107
150
  /** A thread as stored: the wire shape plus the archive flag sessions expose. */
108
151
  export interface StoredThread extends ForgeThread {
109
152
  archived: boolean
@@ -206,6 +249,20 @@ function runFromRow(row: RunRow): StoredRun {
206
249
  }
207
250
  }
208
251
 
252
+ function touchFromRow(row: TouchRow): ForgeArtifactTouch {
253
+ return {
254
+ id: row.id,
255
+ threadId: row.thread_id,
256
+ runId: row.run_id,
257
+ kind: row.kind as ForgeArtifactKind,
258
+ verb: row.verb,
259
+ ref: row.ref,
260
+ live: row.live === 1,
261
+ at: row.at,
262
+ details: row.details ? (JSON.parse(row.details) as ForgeArtifactTouchDetails) : null,
263
+ }
264
+ }
265
+
209
266
  export class ForgeStore {
210
267
  private readonly db: Database
211
268
  /** A store from before host-native modes still carries a NOT NULL column. */
@@ -382,9 +439,64 @@ export class ForgeStore {
382
439
  deleteThread(id: string): void {
383
440
  this.db.query('DELETE FROM messages WHERE thread_id = ?').run(id)
384
441
  this.db.query('DELETE FROM runs WHERE thread_id = ?').run(id)
442
+ this.db.query('DELETE FROM touches WHERE thread_id = ?').run(id)
385
443
  this.db.query('DELETE FROM threads WHERE id = ?').run(id)
386
444
  }
387
445
 
446
+ // -- touches --------------------------------------------------------------
447
+
448
+ /**
449
+ * Oldest first: the pane walks this as history, and "the latest" is the
450
+ * last row. `seq` orders rather than `at`, so two touches from one command
451
+ * line (`frontera agent apply a && frontera agent publish a`) keep the
452
+ * order the shell ran them in even inside the same millisecond.
453
+ */
454
+ listTouches(threadId: string): ForgeArtifactTouch[] {
455
+ return this.db
456
+ .query<TouchRow, [string]>('SELECT * FROM touches WHERE thread_id = ? ORDER BY seq ASC')
457
+ .all(threadId)
458
+ .map(touchFromRow)
459
+ }
460
+
461
+ insertTouch(input: NewTouch): ForgeArtifactTouch {
462
+ const last = this.db
463
+ .query<{ seq: number | null }, [string]>('SELECT MAX(seq) AS seq FROM touches WHERE thread_id = ?')
464
+ .get(input.threadId)
465
+ const seq = (last?.seq ?? -1) + 1
466
+ const id = crypto.randomUUID()
467
+ this.db
468
+ .query('INSERT INTO touches (id, thread_id, run_id, seq, kind, verb, ref, live, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
469
+ .run(id, input.threadId, input.runId, seq, input.kind, input.verb, input.ref, input.live ? 1 : 0, now())
470
+ const row = this.db.query<TouchRow, [string]>('SELECT * FROM touches WHERE id = ?').get(id)
471
+ return touchFromRow(row!)
472
+ }
473
+
474
+ /**
475
+ * The last touch on the same agent, BEFORE this one in the thread, that
476
+ * already carries details — what a delta is computed against. "Same agent"
477
+ * is the same ref, or the same resolved name: the CLI addresses by slug and
478
+ * MCP by uuid. Before, not merely other: two touches from one command line
479
+ * enrich concurrently, and the earlier must not diff against the later.
480
+ */
481
+ previousEnrichedAgentTouch(threadId: string, touchId: string, match: { ref: string | null; label?: string }): ForgeArtifactTouch | null {
482
+ const row = this.db
483
+ .query<TouchRow, [string, string, string | null, string | null]>(
484
+ `SELECT * FROM touches WHERE thread_id = ? AND kind = 'agent' AND details IS NOT NULL
485
+ AND seq < (SELECT seq FROM touches WHERE id = ?)
486
+ AND (ref = ? OR json_extract(details, '$.label') = ?)
487
+ ORDER BY seq DESC LIMIT 1`,
488
+ )
489
+ .get(threadId, touchId, match.ref, match.label ?? null)
490
+ return row ? touchFromRow(row) : null
491
+ }
492
+
493
+ /** Fills in what the enricher found; a touch deleted meanwhile is a no-op. */
494
+ updateTouchDetails(id: string, details: ForgeArtifactTouchDetails): ForgeArtifactTouch | null {
495
+ this.db.query('UPDATE touches SET details = ? WHERE id = ?').run(JSON.stringify(details), id)
496
+ const row = this.db.query<TouchRow, [string]>('SELECT * FROM touches WHERE id = ?').get(id)
497
+ return row ? touchFromRow(row) : null
498
+ }
499
+
388
500
  // -- messages -------------------------------------------------------------
389
501
 
390
502
  listMessages(threadId: string): UIMessage[] {
@@ -99,7 +99,11 @@ export class TerminalManager {
99
99
  try {
100
100
  proc = Bun.spawn([shell, '-l'], {
101
101
  cwd: project.root,
102
- env: forgeEnv({ TERM: 'xterm-256color', COLORTERM: 'truecolor' }),
102
+ env: forgeEnv({
103
+ TERM: 'xterm-256color',
104
+ COLORTERM: 'truecolor',
105
+ ...(project.profile ? { FRONTERA_PROFILE: project.profile } : {}),
106
+ }),
103
107
  terminal: {
104
108
  cols,
105
109
  rows,