feat(tui-go): grant scope picker (A) + standing-grants viewer
Surface the new cross-session grant scopes in the TUI. - Approve-always (A) now opens a scope picker instead of immediately creating a SESSION grant: choose this session / this project / everywhere. SESSION stays the default (A then enter/s = old behaviour); p and g create PROJECT / GLOBAL grants. The grant + approval are sent on confirm (esc cancels, leaving the call pending). - New "grants" palette command + G shortcut open a standing-grants viewer (OverlayGrants) listing the active PROJECT/GLOBAL grants — scope, tool, permitted tiers, project path — with x/enter to revoke. The server's RevokeGrant reply (a fresh grant.list) refreshes it in place. - protocol.go: TypeGrantList + GrantDto; RevokeGrant/ListGrants encoders; CreateGrant now documents the wider scopes. server.go decodes grant.list into m.grants and treats it as a non-session global reply. - render-matrix coverage: grant.list classified as a non-rendering query reply (populates the overlay, not the transcript). - demo.go: grants + grant-scope preview kinds. go build / vet / test green; rendered both overlays via cmd/preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -152,6 +152,30 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.overlay = OverlayStats
|
m.overlay = OverlayStats
|
||||||
m.statsFor = "04a546aa"
|
m.statsFor = "04a546aa"
|
||||||
m.stats = sampleStats()
|
m.stats = sampleStats()
|
||||||
|
|
||||||
|
case "grant-scope":
|
||||||
|
m.connected = true
|
||||||
|
m.currentModel = "llama-cpp:default"
|
||||||
|
m.sessions = sampleSessions()
|
||||||
|
m.selectedID = "04a546aa"
|
||||||
|
m.sessionEntered = true
|
||||||
|
m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"}
|
||||||
|
m.grantScopeIndex = 1
|
||||||
|
m.overlay = OverlayGrantScope
|
||||||
|
|
||||||
|
case "grants":
|
||||||
|
m.connected = true
|
||||||
|
m.currentModel = "llama-cpp:default"
|
||||||
|
m.sessions = sampleSessions()
|
||||||
|
m.selectedID = "04a546aa"
|
||||||
|
m.sessionEntered = true
|
||||||
|
m.grants = []protocol.GrantDto{
|
||||||
|
{GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}},
|
||||||
|
{GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"},
|
||||||
|
{GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}},
|
||||||
|
}
|
||||||
|
m.grantIndex = 1
|
||||||
|
m.overlay = OverlayGrants
|
||||||
}
|
}
|
||||||
return m.View()
|
return m.View()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const (
|
|||||||
OverlaySessions
|
OverlaySessions
|
||||||
OverlayFiles
|
OverlayFiles
|
||||||
OverlayStatusbar
|
OverlayStatusbar
|
||||||
|
OverlayGrants
|
||||||
|
OverlayGrantScope
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
@@ -320,6 +322,15 @@ type Model struct {
|
|||||||
sbHidden map[string]bool
|
sbHidden map[string]bool
|
||||||
sbIndex int
|
sbIndex int
|
||||||
|
|
||||||
|
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
|
||||||
|
grants []protocol.GrantDto
|
||||||
|
grantsLoading bool
|
||||||
|
grantIndex int
|
||||||
|
// grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval.
|
||||||
|
// grantScopeIndex selects session/project/global; grantFor holds the approval being widened.
|
||||||
|
grantScopeIndex int
|
||||||
|
grantFor *Approval
|
||||||
|
|
||||||
// animation
|
// animation
|
||||||
frame int // tick counter; drives spinner + caret blink
|
frame int // tick counter; drives spinner + caret blink
|
||||||
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
|
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.filesModal())
|
return m.center(m.filesModal())
|
||||||
case OverlayStatusbar:
|
case OverlayStatusbar:
|
||||||
return m.center(m.statusbarModal())
|
return m.center(m.statusbarModal())
|
||||||
|
case OverlayGrants:
|
||||||
|
return m.center(m.grantsModal())
|
||||||
|
case OverlayGrantScope:
|
||||||
|
return m.center(m.grantScopeModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -615,6 +619,75 @@ func (m Model) statusbarModal() string {
|
|||||||
return m.center(modal)
|
return m.center(modal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
|
||||||
|
// These are cross-session auto-approvals, so the board opens from anywhere.
|
||||||
|
func (m Model) grantsModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("standing grants") + "\n\n")
|
||||||
|
b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.grantsLoading:
|
||||||
|
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||||
|
case len(m.grants) == 0:
|
||||||
|
b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n")
|
||||||
|
default:
|
||||||
|
for i, g := range m.grants {
|
||||||
|
marker := mbg(t, " ", t.P.BgPanel)
|
||||||
|
fg := t.P.Fg
|
||||||
|
if i == m.grantIndex {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||||
|
fg = t.P.FgStrong
|
||||||
|
}
|
||||||
|
scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
|
||||||
|
tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
|
||||||
|
tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
|
||||||
|
b.WriteString(marker + scope + tool + tiers)
|
||||||
|
if g.Scope == "PROJECT" && g.ProjectID != "" {
|
||||||
|
b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
|
||||||
|
modal := t.Overlay.Width(w).Render(b.String())
|
||||||
|
return m.center(modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
|
||||||
|
// for the pending tool — this session, this project, or everywhere.
|
||||||
|
func (m Model) grantScopeModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
tool := ""
|
||||||
|
if m.grantFor != nil {
|
||||||
|
tool = m.grantFor.ToolName
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
|
||||||
|
b.WriteString(mbg(t, " stop asking for ", t.P.Faint) +
|
||||||
|
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")
|
||||||
|
|
||||||
|
for i, opt := range grantScopeOptions() {
|
||||||
|
marker := mbg(t, " ", t.P.BgPanel)
|
||||||
|
labelFg := t.P.Fg
|
||||||
|
if i == m.grantScopeIndex {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||||
|
labelFg = t.P.FgStrong
|
||||||
|
}
|
||||||
|
key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
|
||||||
|
title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
|
||||||
|
b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
|
||||||
|
modal := t.Overlay.Width(w).Render(b.String())
|
||||||
|
return m.center(modal)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) toolPaletteModal() string {
|
func (m Model) toolPaletteModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ var nonRenderingEventTypes = map[string]string{
|
|||||||
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
|
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
|
||||||
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
|
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
|
||||||
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
|
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
|
||||||
|
protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript",
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
|
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if m.fileIndex >= len(m.filteredFiles()) {
|
if m.fileIndex >= len(m.filteredFiles()) {
|
||||||
m.fileIndex = 0
|
m.fileIndex = 0
|
||||||
}
|
}
|
||||||
|
case protocol.TypeGrantList:
|
||||||
|
m.grants = msg.Grants
|
||||||
|
m.grantsLoading = false
|
||||||
|
if m.grantIndex >= len(m.grants) {
|
||||||
|
m.grantIndex = 0
|
||||||
|
}
|
||||||
case protocol.TypeConfigSnapshot:
|
case protocol.TypeConfigSnapshot:
|
||||||
m.configFields = msg.ConfigFields
|
m.configFields = msg.ConfigFields
|
||||||
m.configRestart = msg.ConfigRestartRequired
|
m.configRestart = msg.ConfigRestartRequired
|
||||||
@@ -378,7 +384,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
|||||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||||
protocol.TypeIdeaList, protocol.TypeFileList:
|
protocol.TypeIdeaList, protocol.TypeFileList, protocol.TypeGrantList:
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
return msg.SessionID
|
return msg.SessionID
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.openSessions()
|
return m, m.openSessions()
|
||||||
case "S":
|
case "S":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
|
case "G":
|
||||||
|
m.openGrants()
|
||||||
case "g":
|
case "g":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "m":
|
case "m":
|
||||||
@@ -573,19 +575,17 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being
|
||||||
|
// asked: this session (the old behaviour), this project, or globally. The actual grant +
|
||||||
|
// approval are sent by applyGrantScope once a scope is picked.
|
||||||
func (m Model) autoApprove() (tea.Model, tea.Cmd) {
|
func (m Model) autoApprove() (tea.Model, tea.Cmd) {
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Pending == nil {
|
if s == nil || s.Pending == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
p := s.Pending
|
m.grantFor = s.Pending
|
||||||
m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName))
|
m.grantScopeIndex = 0
|
||||||
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
|
m.overlay = OverlayGrantScope
|
||||||
s.removeApproval(p.RequestID)
|
|
||||||
m.steerBuffer = ""
|
|
||||||
m.steering = false
|
|
||||||
m.approvalArmed = false
|
|
||||||
m.approvalDismissed = false
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,10 +748,108 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
}
|
}
|
||||||
|
case OverlayGrants:
|
||||||
|
switch {
|
||||||
|
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||||
|
if m.grantIndex > 0 {
|
||||||
|
m.grantIndex--
|
||||||
|
}
|
||||||
|
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
||||||
|
if m.grantIndex < len(m.grants)-1 {
|
||||||
|
m.grantIndex++
|
||||||
|
}
|
||||||
|
case k.Type == tea.KeyEnter || runeIs(k, "x"):
|
||||||
|
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
|
||||||
|
m.client.Send(protocol.RevokeGrant(m.grants[m.grantIndex].GrantID))
|
||||||
|
// The server replies with a fresh grant.list, which refreshes m.grants.
|
||||||
|
}
|
||||||
|
case runeIs(k, "G"):
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
}
|
||||||
|
case OverlayGrantScope:
|
||||||
|
return m.handleGrantScopeKey(k)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the
|
||||||
|
// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on
|
||||||
|
// the same repo, GLOBAL every session on this machine.
|
||||||
|
type grantScopeOption struct{ key, wire, title, hint string }
|
||||||
|
|
||||||
|
func grantScopeOptions() []grantScopeOption {
|
||||||
|
return []grantScopeOption{
|
||||||
|
{"s", "SESSION", "this session", "until this session ends"},
|
||||||
|
{"p", "PROJECT", "this project", "any session on this repo"},
|
||||||
|
{"g", "GLOBAL", "everywhere", "any session on this machine"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
|
||||||
|
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
|
||||||
|
func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
opts := grantScopeOptions()
|
||||||
|
switch {
|
||||||
|
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||||
|
if m.grantScopeIndex > 0 {
|
||||||
|
m.grantScopeIndex--
|
||||||
|
}
|
||||||
|
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
||||||
|
if m.grantScopeIndex < len(opts)-1 {
|
||||||
|
m.grantScopeIndex++
|
||||||
|
}
|
||||||
|
case runeIs(k, "s"):
|
||||||
|
m.grantScopeIndex = 0
|
||||||
|
return m.applyGrantScope()
|
||||||
|
case runeIs(k, "p"):
|
||||||
|
m.grantScopeIndex = 1
|
||||||
|
return m.applyGrantScope()
|
||||||
|
case runeIs(k, "g"):
|
||||||
|
m.grantScopeIndex = 2
|
||||||
|
return m.applyGrantScope()
|
||||||
|
case k.Type == tea.KeyEnter:
|
||||||
|
return m.applyGrantScope()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current
|
||||||
|
// call, and closes the picker. The SESSION choice is identical to the old A behaviour.
|
||||||
|
func (m Model) applyGrantScope() (tea.Model, tea.Cmd) {
|
||||||
|
p := m.grantFor
|
||||||
|
if p == nil {
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
opts := grantScopeOptions()
|
||||||
|
idx := m.grantScopeIndex
|
||||||
|
if idx < 0 || idx >= len(opts) {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
scope := opts[idx].wire
|
||||||
|
m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName))
|
||||||
|
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
|
||||||
|
if s := m.session(p.SessionID); s != nil {
|
||||||
|
s.removeApproval(p.RequestID)
|
||||||
|
}
|
||||||
|
m.grantFor = nil
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
m.steerBuffer = ""
|
||||||
|
m.steering = false
|
||||||
|
m.approvalArmed = false
|
||||||
|
m.approvalDismissed = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants.
|
||||||
|
// The board is global (not session-scoped), so it opens from anywhere.
|
||||||
|
func (m *Model) openGrants() {
|
||||||
|
m.overlay = OverlayGrants
|
||||||
|
m.grantIndex = 0
|
||||||
|
m.grantsLoading = true
|
||||||
|
m.client.Send(protocol.ListGrants())
|
||||||
|
}
|
||||||
|
|
||||||
// toggleStatusSegment flips a status-bar segment's visibility and persists the change.
|
// toggleStatusSegment flips a status-bar segment's visibility and persists the change.
|
||||||
func (m *Model) toggleStatusSegment(id string) {
|
func (m *Model) toggleStatusSegment(id string) {
|
||||||
if m.sbHidden == nil {
|
if m.sbHidden == nil {
|
||||||
@@ -869,6 +967,7 @@ func paletteCommands() []paletteCmd {
|
|||||||
{"stats", "S", "session stats", "metrics for the selected session"},
|
{"stats", "S", "session stats", "metrics for the selected session"},
|
||||||
{"sessions", "R", "resume session", "browse / resume recent sessions"},
|
{"sessions", "R", "resume session", "browse / resume recent sessions"},
|
||||||
{"config", "g", "edit config", "view / change correx settings"},
|
{"config", "g", "edit config", "view / change correx settings"},
|
||||||
|
{"grants", "G", "grants", "view / revoke standing grants"},
|
||||||
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
||||||
{"mode", "s", "toggle mode", "switch chat / steering"},
|
{"mode", "s", "toggle mode", "switch chat / steering"},
|
||||||
{"cancel", "c", "cancel session", "stop the selected session"},
|
{"cancel", "c", "cancel session", "stop the selected session"},
|
||||||
@@ -912,6 +1011,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.openSessions()
|
return m, m.openSessions()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
|
case "grants":
|
||||||
|
m.openGrants()
|
||||||
case "statusbar":
|
case "statusbar":
|
||||||
m.overlay = OverlayStatusbar
|
m.overlay = OverlayStatusbar
|
||||||
m.sbIndex = 0
|
m.sbIndex = 0
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const (
|
|||||||
TypeSessionStats = "session.stats"
|
TypeSessionStats = "session.stats"
|
||||||
TypeIdeaList = "idea.list"
|
TypeIdeaList = "idea.list"
|
||||||
TypeFileList = "file.list"
|
TypeFileList = "file.list"
|
||||||
|
TypeGrantList = "grant.list"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||||
@@ -154,6 +155,20 @@ type ServerMessage struct {
|
|||||||
|
|
||||||
// file.list — the session workspace's file paths (for the @ file-ref picker)
|
// file.list — the session workspace's file paths (for the @ file-ref picker)
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
|
|
||||||
|
// grant.list — the active cross-session (PROJECT/GLOBAL) standing grants
|
||||||
|
Grants []GrantDto `json:"grants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto.
|
||||||
|
type GrantDto struct {
|
||||||
|
GrantID string `json:"grantId"`
|
||||||
|
Scope string `json:"scope"` // "PROJECT" | "GLOBAL"
|
||||||
|
ToolName string `json:"toolName"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
Tiers []string `json:"tiers"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ExpiresAtMs *int64 `json:"expiresAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
|
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
|
||||||
@@ -457,7 +472,8 @@ func ClearModelPin() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
|
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
|
||||||
// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3".
|
// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3".
|
||||||
|
// For PROJECT/GLOBAL the server derives the projectId from the session's workspace.
|
||||||
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
|
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
|
||||||
return encode("CreateGrant", map[string]any{
|
return encode("CreateGrant", map[string]any{
|
||||||
"sessionId": sessionID,
|
"sessionId": sessionID,
|
||||||
@@ -470,6 +486,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list.
|
||||||
|
func RevokeGrant(grantID string) []byte {
|
||||||
|
return encode("RevokeGrant", map[string]any{"grantId": grantID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGrants requests the active cross-session grants (replied to with a grant.list).
|
||||||
|
func ListGrants() []byte {
|
||||||
|
return encode("ListGrants", map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
// Hello is the first frame sent on every (re)connect. It carries the client's
|
// Hello is the first frame sent on every (re)connect. It carries the client's
|
||||||
// working directory so the server can bind a workspace before any session starts.
|
// working directory so the server can bind a workspace before any session starts.
|
||||||
func Hello(workingDir string) []byte {
|
func Hello(workingDir string) []byte {
|
||||||
|
|||||||
Reference in New Issue
Block a user