@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 +4 -4
- package/src/commands/agent/draft-changes.ts +85 -0
- package/src/commands/agent/index-commands.ts +10 -74
- package/src/commands/setup.ts +30 -2
- package/src/forge/claude-adapter.ts +4 -2
- package/src/forge/projects.ts +29 -7
- package/src/forge/runs.ts +43 -0
- package/src/forge/server.ts +10 -1
- package/src/forge/store.ts +113 -1
- package/src/forge/terminals.ts +5 -1
- package/src/forge/touch-details.ts +90 -0
- package/src/forge/touches.ts +412 -0
- package/src/project-context.ts +1 -1
- package/src/vendor/sdk-sources.json +14 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.50.
|
|
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.
|
|
43
|
-
"@frontera-sdk/core": "1.50.
|
|
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.
|
|
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
|
|
375
|
-
|
|
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 (!
|
|
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
|
|
373
|
+
data: { hasDraft: true, revision, published, changed },
|
|
438
374
|
text:
|
|
439
375
|
changed.length > 0
|
|
440
|
-
? [`Draft revision ${
|
|
376
|
+
? [`Draft revision ${revision} differs in:`, ...changed.map((k) => ` ${k}`)].join('\n')
|
|
441
377
|
: published
|
|
442
|
-
? `Draft revision ${
|
|
443
|
-
: `Draft revision ${
|
|
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
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
package/src/forge/projects.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
296
|
-
} catch
|
|
297
|
-
|
|
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 }
|
package/src/forge/server.ts
CHANGED
|
@@ -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
|
package/src/forge/store.ts
CHANGED
|
@@ -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 {
|
|
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[] {
|
package/src/forge/terminals.ts
CHANGED
|
@@ -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({
|
|
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,
|