factory-ai 1.6.1 → 2.1.0
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/CHANGELOG.md +17 -2
- package/README.md +2 -0
- package/bin/factory +6 -2
- package/cmd/factory-ui/main.go +426 -65
- package/package.json +1 -3
- package/src/approval-cli.js +17 -0
- package/src/tui.js +0 -173
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.0] - 2026-07-13
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Redesigned the native TUI around a persistent workspace selector with workspace-scoped Dashboard, Objectives, and Agents pages.
|
|
12
|
+
- Consolidated secrets, logs, models, runtime, and capabilities under Settings.
|
|
13
|
+
- Added dedicated bounded command-console and always-visible command-line panes with scrollback, clear controls, history, inline errors, and selected-workspace submit shortcuts.
|
|
14
|
+
|
|
15
|
+
## [2.0.0] - 2026-07-13
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Replaced the JavaScript fallback TUI with one native Go operator console featuring an OpenCode-style command bar, command history, inline output/errors, and shortcuts for submissions, workspace imports, secrets, approvals, pause/resume, and help.
|
|
20
|
+
- Removed the `factory-ui` JavaScript binary and `neo-blessed` dependency; `factory ui` now fails closed if its verified attested native binary is unavailable.
|
|
21
|
+
|
|
7
22
|
## [1.6.1] - 2026-07-13
|
|
8
23
|
|
|
9
24
|
### Added
|
|
@@ -14,7 +29,7 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
|
|
|
14
29
|
|
|
15
30
|
### Added
|
|
16
31
|
|
|
17
|
-
- Persistent Conductor-style workspace catalog with local/GitHub imports, managed clones, stable names, default branches, project initialization, CLI commands, direct Service Bus submission, and
|
|
32
|
+
- Persistent Conductor-style workspace catalog with local/GitHub imports, managed clones, stable names, default branches, project initialization, CLI commands, direct Service Bus submission, and a native workspace view.
|
|
18
33
|
|
|
19
34
|
## [1.5.3] - 2026-07-13
|
|
20
35
|
|
|
@@ -38,7 +53,7 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
|
|
|
38
53
|
|
|
39
54
|
### Added
|
|
40
55
|
|
|
41
|
-
- Cross-platform Bubble Tea/Lip Gloss operator client with direct Azure Blob snapshots, verified release binaries, scrolling, and
|
|
56
|
+
- Cross-platform Bubble Tea/Lip Gloss operator client with direct Azure Blob snapshots, verified release binaries, scrolling, and native command execution.
|
|
42
57
|
- Automatic Azure and Bedrock context compaction with immutable objective retention and recent tool evidence.
|
|
43
58
|
- Hierarchical `AGENTS.md` discovery, preconfigured project instructions, and bounded `.agent-factory` context.
|
|
44
59
|
- Durable worktree crash recovery, parallel read-only tool batches, live activity timelines, retry/error telemetry, and stale-agent watchdog recovery.
|
package/README.md
CHANGED
|
@@ -96,6 +96,8 @@ factory ui
|
|
|
96
96
|
| `factory secret set NAME` | Store a credential in global Key Vault |
|
|
97
97
|
| `factory github connect ORG` | Connect GitHub Enterprise credentials |
|
|
98
98
|
|
|
99
|
+
Inside `factory ui`, select a workspace from the persistent left sidebar with `[`/`]`; Dashboard, Objectives, and Agents are scoped to it. Settings contains secrets, logs, models, runtime, and capabilities. Press `:` or Enter to focus the dedicated command line and enter any Factory command without the `factory` prefix. Use `↑/↓` for command history and `Ctrl+↑/↓` for console scrollback. Shortcuts: `n` submit to the selected workspace, `i` import, `a` set secret, `y/x` approve or deny, `p/u` pause or resume, `?` help, `Ctrl+L` clear console, and `q` quit.
|
|
100
|
+
|
|
99
101
|
## Architecture
|
|
100
102
|
|
|
101
103
|
```mermaid
|
package/bin/factory
CHANGED
|
@@ -31,6 +31,7 @@ usage() {
|
|
|
31
31
|
' workspace list | import PATH|OWNER/REPO [--name NAME] | show NAME | remove NAME' \
|
|
32
32
|
' submit <workspace|owner/repo> <objective>' \
|
|
33
33
|
' update check | now | status | enable | disable' \
|
|
34
|
+
' approval approve | deny OBJECTIVE_ID APPROVAL_ID REASON' \
|
|
34
35
|
' issue <owner/repo> <number>' \
|
|
35
36
|
' ui' \
|
|
36
37
|
' init <local-project-path>' \
|
|
@@ -114,6 +115,9 @@ case "$command" in
|
|
|
114
115
|
*) printf 'Usage: factory update check | now | status | enable | disable\n' >&2; exit 2 ;;
|
|
115
116
|
esac
|
|
116
117
|
;;
|
|
118
|
+
approval)
|
|
119
|
+
SERVICE_BUS_NAMESPACE="$NAMESPACE" CONTROL_QUEUE=control-events exec node "$FACTORY_ROOT/src/approval-cli.js" "$@"
|
|
120
|
+
;;
|
|
117
121
|
acp)
|
|
118
122
|
request=${1:?ACP request JSON file required}
|
|
119
123
|
SERVICE_BUS_NAMESPACE="$NAMESPACE" CONTROL_QUEUE=control-events FACTORY_ACP_ENABLED=true exec node "$FACTORY_ROOT/src/acp-cli.js" "$request"
|
|
@@ -236,8 +240,8 @@ case "$command" in
|
|
|
236
240
|
if [[ -x $binary ]]; then
|
|
237
241
|
FACTORY_STORAGE_ACCOUNT="$STORAGE" FACTORY_NAME="$FACTORY_NAME" FACTORY_PURPOSE="$FACTORY_PURPOSE" exec "$binary"
|
|
238
242
|
fi
|
|
239
|
-
printf '
|
|
240
|
-
|
|
243
|
+
printf 'Verified Factory TUI binary is unavailable for %s/%s. Check GitHub release assets and `gh auth status`.\n' "$os" "$arch" >&2
|
|
244
|
+
exit 1
|
|
241
245
|
;;
|
|
242
246
|
setup)
|
|
243
247
|
for dependency in az gh jq curl ssh-keygen node; do command -v "$dependency" >/dev/null || { printf 'Missing dependency: %s\n' "$dependency" >&2; exit 1; }; done
|
package/cmd/factory-ui/main.go
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"context"
|
|
5
6
|
"encoding/json"
|
|
6
7
|
"fmt"
|
|
@@ -9,8 +10,10 @@ import (
|
|
|
9
10
|
"os/exec"
|
|
10
11
|
"path/filepath"
|
|
11
12
|
"regexp"
|
|
13
|
+
"sort"
|
|
12
14
|
"strconv"
|
|
13
15
|
"strings"
|
|
16
|
+
"sync"
|
|
14
17
|
"time"
|
|
15
18
|
|
|
16
19
|
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
|
@@ -48,6 +51,12 @@ type objective struct {
|
|
|
48
51
|
Tasks []task `json:"tasks"`
|
|
49
52
|
PullRequest string `json:"pullRequest"`
|
|
50
53
|
Blocker string `json:"blocker"`
|
|
54
|
+
Approval *struct {
|
|
55
|
+
ApprovalID string `json:"approvalId"`
|
|
56
|
+
Status string `json:"status"`
|
|
57
|
+
Policy string `json:"policy"`
|
|
58
|
+
Reason string `json:"reason"`
|
|
59
|
+
} `json:"approval"`
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
type workspace struct {
|
|
@@ -96,22 +105,32 @@ type snapshotMsg struct {
|
|
|
96
105
|
err error
|
|
97
106
|
}
|
|
98
107
|
type tickMsg time.Time
|
|
99
|
-
type
|
|
108
|
+
type commandResultMsg struct {
|
|
109
|
+
command, output string
|
|
110
|
+
err error
|
|
111
|
+
}
|
|
100
112
|
|
|
101
113
|
type model struct {
|
|
102
|
-
client
|
|
103
|
-
factoryName
|
|
104
|
-
purpose
|
|
105
|
-
width
|
|
106
|
-
height
|
|
107
|
-
tab
|
|
108
|
-
scroll
|
|
109
|
-
dashboard
|
|
110
|
-
logs
|
|
111
|
-
workspaces
|
|
112
|
-
workspaceErr
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
client *azblob.Client
|
|
115
|
+
factoryName string
|
|
116
|
+
purpose string
|
|
117
|
+
width int
|
|
118
|
+
height int
|
|
119
|
+
tab int
|
|
120
|
+
scroll int
|
|
121
|
+
dashboard dashboard
|
|
122
|
+
logs string
|
|
123
|
+
workspaces []workspace
|
|
124
|
+
workspaceErr string
|
|
125
|
+
selectedWorkspace string
|
|
126
|
+
loading bool
|
|
127
|
+
err error
|
|
128
|
+
commandMode bool
|
|
129
|
+
commandInput string
|
|
130
|
+
commandOutput string
|
|
131
|
+
commandHistory []string
|
|
132
|
+
historyIndex int
|
|
133
|
+
consoleScroll int
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
var (
|
|
@@ -122,10 +141,26 @@ var (
|
|
|
122
141
|
muted = lipgloss.Color("#7F8B99")
|
|
123
142
|
panel = lipgloss.Color("#15191F")
|
|
124
143
|
border = lipgloss.Color("#303743")
|
|
125
|
-
tabs = []string{"
|
|
144
|
+
tabs = []string{"Dashboard", "Objectives", "Agents", "Settings"}
|
|
126
145
|
ansi = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)`)
|
|
127
146
|
)
|
|
128
147
|
|
|
148
|
+
type synchronizedBuffer struct {
|
|
149
|
+
mu sync.Mutex
|
|
150
|
+
buffer bytes.Buffer
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func (value *synchronizedBuffer) Write(data []byte) (int, error) {
|
|
154
|
+
value.mu.Lock()
|
|
155
|
+
defer value.mu.Unlock()
|
|
156
|
+
return value.buffer.Write(data)
|
|
157
|
+
}
|
|
158
|
+
func (value *synchronizedBuffer) String() string {
|
|
159
|
+
value.mu.Lock()
|
|
160
|
+
defer value.mu.Unlock()
|
|
161
|
+
return value.buffer.String()
|
|
162
|
+
}
|
|
163
|
+
|
|
129
164
|
func clean(value string) string {
|
|
130
165
|
value = ansi.ReplaceAllString(value, "")
|
|
131
166
|
return strings.Map(func(character rune) rune {
|
|
@@ -136,6 +171,96 @@ func clean(value string) string {
|
|
|
136
171
|
}, value)
|
|
137
172
|
}
|
|
138
173
|
|
|
174
|
+
func parseCommandLine(value string) ([]string, error) {
|
|
175
|
+
var args []string
|
|
176
|
+
var current strings.Builder
|
|
177
|
+
var quote rune
|
|
178
|
+
escaped := false
|
|
179
|
+
flush := func() {
|
|
180
|
+
if current.Len() > 0 {
|
|
181
|
+
args = append(args, current.String())
|
|
182
|
+
current.Reset()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for _, character := range strings.TrimSpace(value) {
|
|
186
|
+
if escaped {
|
|
187
|
+
current.WriteRune(character)
|
|
188
|
+
escaped = false
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if character == '\\' && quote != '\'' {
|
|
192
|
+
escaped = true
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if quote != 0 {
|
|
196
|
+
if character == quote {
|
|
197
|
+
quote = 0
|
|
198
|
+
} else {
|
|
199
|
+
current.WriteRune(character)
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if character == '\'' || character == '"' {
|
|
204
|
+
quote = character
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if character == ' ' || character == '\t' {
|
|
208
|
+
flush()
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
current.WriteRune(character)
|
|
212
|
+
}
|
|
213
|
+
if escaped || quote != 0 {
|
|
214
|
+
return nil, fmt.Errorf("unterminated quote or escape")
|
|
215
|
+
}
|
|
216
|
+
flush()
|
|
217
|
+
if len(args) > 0 && args[0] == "factory" {
|
|
218
|
+
args = args[1:]
|
|
219
|
+
}
|
|
220
|
+
return args, nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func validateFactoryCommand(args []string) error {
|
|
224
|
+
if len(args) == 0 {
|
|
225
|
+
return fmt.Errorf("enter a factory command")
|
|
226
|
+
}
|
|
227
|
+
allowed := map[string]bool{"setup": true, "configure": true, "models": true, "acp": true, "extension": true, "github": true, "telegram": true, "workspace": true, "submit": true, "issue": true, "init": true, "secret": true, "dashboard": true, "status": true, "queue": true, "report": true, "logs": true, "doctor": true, "pause": true, "resume": true, "update": true, "approval": true, "help": true, "--help": true, "-h": true}
|
|
228
|
+
if args[0] == "ui" {
|
|
229
|
+
return fmt.Errorf("the UI command cannot be launched inside itself")
|
|
230
|
+
}
|
|
231
|
+
if !allowed[args[0]] {
|
|
232
|
+
return fmt.Errorf("unknown factory command: %s", args[0])
|
|
233
|
+
}
|
|
234
|
+
return nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func interactiveFactoryCommand(args []string) bool {
|
|
238
|
+
if len(args) == 0 {
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
return args[0] == "setup" || args[0] == "configure" || args[0] == "secret" && len(args) > 1 && args[1] == "set" || args[0] == "telegram" && len(args) > 1 && args[1] == "configure"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func executeFactoryCommand(args []string) tea.Cmd {
|
|
245
|
+
return func() tea.Msg {
|
|
246
|
+
command := "factory " + strings.Join(args, " ")
|
|
247
|
+
output, err := exec.Command("factory", args...).CombinedOutput()
|
|
248
|
+
return commandResultMsg{command: command, output: string(output), err: err}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func interactiveFactoryProcess(args []string) tea.Cmd {
|
|
253
|
+
var transcript synchronizedBuffer
|
|
254
|
+
command := exec.Command("factory", args...)
|
|
255
|
+
command.Stdin = os.Stdin
|
|
256
|
+
command.Stdout = io.MultiWriter(os.Stdout, &transcript)
|
|
257
|
+
command.Stderr = io.MultiWriter(os.Stderr, &transcript)
|
|
258
|
+
label := "factory " + strings.Join(args, " ")
|
|
259
|
+
return tea.ExecProcess(command, func(err error) tea.Msg {
|
|
260
|
+
return commandResultMsg{command: label, output: transcript.String(), err: err}
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
139
264
|
func readConfig() map[string]string {
|
|
140
265
|
result := map[string]string{}
|
|
141
266
|
for _, key := range []string{"FACTORY_STORAGE_ACCOUNT", "FACTORY_NAME", "FACTORY_PURPOSE"} {
|
|
@@ -203,6 +328,57 @@ func (m model) Init() tea.Cmd { return tea.Batch(fetchCmd(m.client), tickCmd())
|
|
|
203
328
|
func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|
204
329
|
switch msg := message.(type) {
|
|
205
330
|
case tea.KeyMsg:
|
|
331
|
+
if m.commandMode {
|
|
332
|
+
switch msg.String() {
|
|
333
|
+
case "esc":
|
|
334
|
+
m.commandMode = false
|
|
335
|
+
m.commandInput = ""
|
|
336
|
+
case "enter":
|
|
337
|
+
line := strings.TrimSpace(m.commandInput)
|
|
338
|
+
args, err := parseCommandLine(line)
|
|
339
|
+
if err == nil {
|
|
340
|
+
err = validateFactoryCommand(args)
|
|
341
|
+
}
|
|
342
|
+
if err != nil {
|
|
343
|
+
m.commandOutput = err.Error()
|
|
344
|
+
return m, nil
|
|
345
|
+
}
|
|
346
|
+
m.commandMode = false
|
|
347
|
+
m.commandInput = ""
|
|
348
|
+
m.commandHistory = append(m.commandHistory, line)
|
|
349
|
+
m.historyIndex = len(m.commandHistory)
|
|
350
|
+
if interactiveFactoryCommand(args) {
|
|
351
|
+
return m, interactiveFactoryProcess(args)
|
|
352
|
+
}
|
|
353
|
+
m.loading = true
|
|
354
|
+
return m, executeFactoryCommand(args)
|
|
355
|
+
case "backspace", "ctrl+h":
|
|
356
|
+
runes := []rune(m.commandInput)
|
|
357
|
+
if len(runes) > 0 {
|
|
358
|
+
m.commandInput = string(runes[:len(runes)-1])
|
|
359
|
+
}
|
|
360
|
+
case "ctrl+u":
|
|
361
|
+
m.commandInput = ""
|
|
362
|
+
case "up":
|
|
363
|
+
if m.historyIndex > 0 {
|
|
364
|
+
m.historyIndex--
|
|
365
|
+
m.commandInput = m.commandHistory[m.historyIndex]
|
|
366
|
+
}
|
|
367
|
+
case "down":
|
|
368
|
+
if m.historyIndex < len(m.commandHistory)-1 {
|
|
369
|
+
m.historyIndex++
|
|
370
|
+
m.commandInput = m.commandHistory[m.historyIndex]
|
|
371
|
+
} else {
|
|
372
|
+
m.historyIndex = len(m.commandHistory)
|
|
373
|
+
m.commandInput = ""
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
if len(msg.Runes) > 0 {
|
|
377
|
+
m.commandInput += string(msg.Runes)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return m, nil
|
|
381
|
+
}
|
|
206
382
|
switch msg.String() {
|
|
207
383
|
case "q", "ctrl+c":
|
|
208
384
|
return m, tea.Quit
|
|
@@ -233,13 +409,67 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
233
409
|
m.scroll += 10
|
|
234
410
|
case "home":
|
|
235
411
|
m.scroll = 0
|
|
236
|
-
case "
|
|
237
|
-
|
|
412
|
+
case "[", "shift+up":
|
|
413
|
+
if index := m.selectedIndex(); index > 0 {
|
|
414
|
+
m.selectedWorkspace = m.workspaces[index-1].Name
|
|
415
|
+
m.scroll = 0
|
|
416
|
+
}
|
|
417
|
+
case "]", "shift+down":
|
|
418
|
+
if index := m.selectedIndex(); index >= 0 && index < len(m.workspaces)-1 {
|
|
419
|
+
m.selectedWorkspace = m.workspaces[index+1].Name
|
|
420
|
+
m.scroll = 0
|
|
421
|
+
}
|
|
422
|
+
case "ctrl+up":
|
|
423
|
+
if m.consoleScroll > 0 {
|
|
424
|
+
m.consoleScroll--
|
|
425
|
+
}
|
|
426
|
+
case "ctrl+down":
|
|
427
|
+
m.consoleScroll++
|
|
428
|
+
case ":", "/", "enter", "o":
|
|
429
|
+
m.commandMode = true
|
|
430
|
+
m.commandInput = ""
|
|
431
|
+
m.historyIndex = len(m.commandHistory)
|
|
432
|
+
case "n":
|
|
433
|
+
m.commandMode = true
|
|
434
|
+
m.commandInput = "submit "
|
|
435
|
+
if selected := m.selected(); selected != nil {
|
|
436
|
+
m.commandInput += selected.Name + " "
|
|
437
|
+
}
|
|
438
|
+
m.historyIndex = len(m.commandHistory)
|
|
439
|
+
case "i":
|
|
440
|
+
m.commandMode = true
|
|
441
|
+
m.commandInput = "workspace import "
|
|
442
|
+
m.historyIndex = len(m.commandHistory)
|
|
443
|
+
case "a":
|
|
444
|
+
m.commandMode = true
|
|
445
|
+
m.commandInput = "secret set "
|
|
446
|
+
m.historyIndex = len(m.commandHistory)
|
|
447
|
+
case "y":
|
|
448
|
+
m.commandMode = true
|
|
449
|
+
m.commandInput = "approval approve "
|
|
450
|
+
m.historyIndex = len(m.commandHistory)
|
|
451
|
+
case "x":
|
|
452
|
+
m.commandMode = true
|
|
453
|
+
m.commandInput = "approval deny "
|
|
454
|
+
m.historyIndex = len(m.commandHistory)
|
|
455
|
+
case "p":
|
|
456
|
+
m.loading = true
|
|
457
|
+
return m, executeFactoryCommand([]string{"pause"})
|
|
458
|
+
case "u":
|
|
459
|
+
m.loading = true
|
|
460
|
+
return m, executeFactoryCommand([]string{"resume"})
|
|
461
|
+
case "?":
|
|
462
|
+
m.loading = true
|
|
463
|
+
return m, executeFactoryCommand([]string{"--help"})
|
|
464
|
+
case "ctrl+l", "esc":
|
|
465
|
+
m.commandOutput = ""
|
|
466
|
+
m.consoleScroll = 0
|
|
467
|
+
m.err = nil
|
|
238
468
|
case "r":
|
|
239
469
|
m.loading = true
|
|
240
470
|
return m, fetchCmd(m.client)
|
|
241
471
|
default:
|
|
242
|
-
if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '
|
|
472
|
+
if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '4' {
|
|
243
473
|
m.tab = int(msg.String()[0] - '1')
|
|
244
474
|
m.scroll = 0
|
|
245
475
|
}
|
|
@@ -251,13 +481,25 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
251
481
|
m.err = msg.err
|
|
252
482
|
if msg.err == nil {
|
|
253
483
|
m.dashboard, m.logs, m.workspaces, m.workspaceErr = msg.dashboard, msg.logs, msg.workspaces, msg.workspaceErr
|
|
484
|
+
if len(m.workspaces) == 0 {
|
|
485
|
+
m.selectedWorkspace = ""
|
|
486
|
+
} else if m.selected() == nil {
|
|
487
|
+
m.selectedWorkspace = m.workspaces[0].Name
|
|
488
|
+
}
|
|
254
489
|
}
|
|
255
|
-
case
|
|
256
|
-
m.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
490
|
+
case commandResultMsg:
|
|
491
|
+
m.loading = false
|
|
492
|
+
m.err = nil
|
|
493
|
+
result := strings.TrimSpace(clean(msg.output))
|
|
494
|
+
if msg.err != nil && result == "" {
|
|
495
|
+
result = msg.err.Error()
|
|
496
|
+
}
|
|
497
|
+
m.commandOutput = fmt.Sprintf("$ %s\n%s", clean(msg.command), result)
|
|
498
|
+
m.consoleScroll = len(strings.Split(m.commandOutput, "\n")) - 4
|
|
499
|
+
if m.consoleScroll < 0 {
|
|
500
|
+
m.consoleScroll = 0
|
|
260
501
|
}
|
|
502
|
+
return m, fetchCmd(m.client)
|
|
261
503
|
case tickMsg:
|
|
262
504
|
if !m.loading {
|
|
263
505
|
m.loading = true
|
|
@@ -291,15 +533,58 @@ func trim(value string, max int) string {
|
|
|
291
533
|
return value
|
|
292
534
|
}
|
|
293
535
|
|
|
536
|
+
func normalizeRepository(value string) string {
|
|
537
|
+
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(value), "https://github.com/"), ".git")
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
func (m model) selected() *workspace {
|
|
541
|
+
for index := range m.workspaces {
|
|
542
|
+
if m.workspaces[index].Name == m.selectedWorkspace {
|
|
543
|
+
return &m.workspaces[index]
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return nil
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
func (m model) selectedIndex() int {
|
|
550
|
+
for index, item := range m.workspaces {
|
|
551
|
+
if item.Name == m.selectedWorkspace {
|
|
552
|
+
return index
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return -1
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
func (m model) visibleObjectives() []objective {
|
|
559
|
+
selected := m.selected()
|
|
560
|
+
if selected == nil {
|
|
561
|
+
return nil
|
|
562
|
+
}
|
|
563
|
+
repository := normalizeRepository(selected.Repository)
|
|
564
|
+
result := []objective{}
|
|
565
|
+
for _, item := range m.dashboard.Objectives {
|
|
566
|
+
if normalizeRepository(item.Repository) == repository {
|
|
567
|
+
result = append(result, item)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return result
|
|
571
|
+
}
|
|
572
|
+
|
|
294
573
|
func (m model) overview() string {
|
|
295
574
|
cost := "unavailable"
|
|
296
575
|
if m.dashboard.Cost != nil {
|
|
297
576
|
cost = fmt.Sprintf("%s %.2f", m.dashboard.Cost.Currency, m.dashboard.Cost.MonthToDate)
|
|
298
577
|
}
|
|
578
|
+
objectives := m.visibleObjectives()
|
|
579
|
+
countMap := map[string]int{}
|
|
580
|
+
for _, item := range objectives {
|
|
581
|
+
countMap[item.Status]++
|
|
582
|
+
}
|
|
299
583
|
counts := []string{}
|
|
300
|
-
for key, value := range
|
|
584
|
+
for key, value := range countMap {
|
|
301
585
|
counts = append(counts, fmt.Sprintf("%s %d", key, value))
|
|
302
586
|
}
|
|
587
|
+
sort.Strings(counts)
|
|
303
588
|
in, cached, out := 0, 0, 0
|
|
304
589
|
for _, value := range m.dashboard.ModelUsage {
|
|
305
590
|
in += value.InputTokens
|
|
@@ -307,8 +592,8 @@ func (m model) overview() string {
|
|
|
307
592
|
out += value.OutputTokens
|
|
308
593
|
}
|
|
309
594
|
value := fmt.Sprintf("%s\n\nHealth %s\nQueue %s\nDead letters %d\nAzure MTD %s\nObjectives %s\nTokens %d in · %d cached · %d out\n\n%s\n\n", lipgloss.NewStyle().Bold(true).Render("SYSTEM"), badge(m.dashboard.Health.Status), lipgloss.NewStyle().Foreground(accent).Render(fmt.Sprint(m.dashboard.Queue.Active)), m.dashboard.Queue.DeadLetter, lipgloss.NewStyle().Foreground(warn).Render(cost), strings.Join(counts, " "), in, cached, out, lipgloss.NewStyle().Bold(true).Render("RECENT OBJECTIVES"))
|
|
310
|
-
for index := len(
|
|
311
|
-
item :=
|
|
595
|
+
for index := len(objectives) - 1; index >= 0 && index >= len(objectives)-8; index-- {
|
|
596
|
+
item := objectives[index]
|
|
312
597
|
value += fmt.Sprintf("%s %s\n %s\n\n", badge(item.Status), trim(item.Objective, 90), lipgloss.NewStyle().Foreground(muted).Render(clean(item.Repository)))
|
|
313
598
|
}
|
|
314
599
|
return value
|
|
@@ -316,8 +601,9 @@ func (m model) overview() string {
|
|
|
316
601
|
|
|
317
602
|
func (m model) objectives() string {
|
|
318
603
|
var value strings.Builder
|
|
319
|
-
|
|
320
|
-
|
|
604
|
+
objectives := m.visibleObjectives()
|
|
605
|
+
for index := len(objectives) - 1; index >= 0; index-- {
|
|
606
|
+
item := objectives[index]
|
|
321
607
|
fmt.Fprintf(&value, "%s %s\n%s\n", badge(item.Status), trim(item.Objective, 100), lipgloss.NewStyle().Foreground(muted).Render(clean(item.ID)))
|
|
322
608
|
for _, task := range item.Tasks {
|
|
323
609
|
state := task.State
|
|
@@ -336,6 +622,9 @@ func (m model) objectives() string {
|
|
|
336
622
|
if item.PullRequest != "" {
|
|
337
623
|
fmt.Fprintf(&value, " PR %s\n", clean(item.PullRequest))
|
|
338
624
|
}
|
|
625
|
+
if item.Approval != nil {
|
|
626
|
+
fmt.Fprintf(&value, " %s approval %s · %s\n ID %s\n", badge(item.Approval.Status), clean(item.Approval.Policy), trim(item.Approval.Reason, 90), clean(item.Approval.ApprovalID))
|
|
627
|
+
}
|
|
339
628
|
value.WriteString("\n")
|
|
340
629
|
}
|
|
341
630
|
return value.String()
|
|
@@ -343,7 +632,7 @@ func (m model) objectives() string {
|
|
|
343
632
|
|
|
344
633
|
func (m model) agents() string {
|
|
345
634
|
var value strings.Builder
|
|
346
|
-
for _, item := range m.
|
|
635
|
+
for _, item := range m.visibleObjectives() {
|
|
347
636
|
for _, task := range item.Tasks {
|
|
348
637
|
if task.State == "succeeded" {
|
|
349
638
|
continue
|
|
@@ -375,47 +664,80 @@ func (m model) agents() string {
|
|
|
375
664
|
return value.String()
|
|
376
665
|
}
|
|
377
666
|
|
|
378
|
-
func (m model)
|
|
667
|
+
func (m model) sidebar(maxLines int) string {
|
|
668
|
+
var value strings.Builder
|
|
669
|
+
value.WriteString(lipgloss.NewStyle().Bold(true).Render("WORKSPACES") + "\n\n")
|
|
379
670
|
if m.workspaceErr != "" {
|
|
380
|
-
|
|
671
|
+
value.WriteString(lipgloss.NewStyle().Foreground(danger).Render("Catalog unavailable") + "\n")
|
|
381
672
|
}
|
|
382
673
|
if len(m.workspaces) == 0 {
|
|
383
|
-
|
|
674
|
+
value.WriteString("No workspaces\n\nPress i to import")
|
|
384
675
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
676
|
+
available := maxLines - 7
|
|
677
|
+
if available < 1 {
|
|
678
|
+
available = 1
|
|
679
|
+
}
|
|
680
|
+
start := 0
|
|
681
|
+
if index := m.selectedIndex(); index >= available {
|
|
682
|
+
start = index - available + 1
|
|
683
|
+
}
|
|
684
|
+
end := start + available
|
|
685
|
+
if end > len(m.workspaces) {
|
|
686
|
+
end = len(m.workspaces)
|
|
389
687
|
}
|
|
688
|
+
if start > 0 {
|
|
689
|
+
value.WriteString(lipgloss.NewStyle().Foreground(muted).Render(" ↑ more") + "\n")
|
|
690
|
+
}
|
|
691
|
+
for _, item := range m.workspaces[start:end] {
|
|
692
|
+
marker := " "
|
|
693
|
+
style := lipgloss.NewStyle().Foreground(muted)
|
|
694
|
+
if item.Name == m.selectedWorkspace {
|
|
695
|
+
marker = "› "
|
|
696
|
+
style = style.Foreground(accent).Bold(true)
|
|
697
|
+
}
|
|
698
|
+
value.WriteString(style.Render(marker+trim(item.Name, 20)) + "\n")
|
|
699
|
+
}
|
|
700
|
+
if end < len(m.workspaces) {
|
|
701
|
+
value.WriteString(lipgloss.NewStyle().Foreground(muted).Render(" ↓ more") + "\n")
|
|
702
|
+
}
|
|
703
|
+
value.WriteString("\n" + lipgloss.NewStyle().Foreground(muted).Render("[ / ] select\ni import\nn new objective"))
|
|
704
|
+
return value.String()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
func (m model) settings() string {
|
|
708
|
+
var value strings.Builder
|
|
709
|
+
value.WriteString("RUNTIME\n")
|
|
710
|
+
fmt.Fprintf(&value, " Name %s\n Purpose %s\n Storage Azure Blob\n Refresh 15 seconds\n\n", clean(m.factoryName), clean(m.purpose))
|
|
711
|
+
value.WriteString("MODELS\n :models show\n :models set ROLE PROVIDER/MODEL\n\nSECRETS (values hidden)\n")
|
|
712
|
+
for _, item := range m.dashboard.Secrets {
|
|
713
|
+
fmt.Fprintf(&value, " ● %-36s %s\n", clean(item.Name), clean(item.Updated))
|
|
714
|
+
}
|
|
715
|
+
if len(m.dashboard.Secrets) == 0 {
|
|
716
|
+
value.WriteString(" No secret metadata available\n")
|
|
717
|
+
}
|
|
718
|
+
value.WriteString("\nCAPABILITIES\n Skills, MCP servers, scanners, ACP, and signed extensions\n :extension verify MANIFEST ARTIFACT PUBLIC_KEY\n")
|
|
719
|
+
value.WriteString("\nRECENT SERVICE LOGS\n")
|
|
720
|
+
lines := strings.Split(clean(m.logs), "\n")
|
|
721
|
+
if len(lines) > 30 {
|
|
722
|
+
lines = lines[len(lines)-30:]
|
|
723
|
+
}
|
|
724
|
+
value.WriteString(strings.Join(lines, "\n"))
|
|
390
725
|
return value.String()
|
|
391
726
|
}
|
|
392
727
|
|
|
393
728
|
func (m model) body() string {
|
|
729
|
+
if m.tab < 3 && m.selected() == nil {
|
|
730
|
+
return "SELECT A WORKSPACE\n\nImport one with i, then select it from the left sidebar.\nObjectives and agents are scoped to the selected workspace."
|
|
731
|
+
}
|
|
394
732
|
switch m.tab {
|
|
395
733
|
case 0:
|
|
396
734
|
return m.overview()
|
|
397
735
|
case 1:
|
|
398
|
-
return m.workspaceList()
|
|
399
|
-
case 2:
|
|
400
736
|
return m.objectives()
|
|
401
|
-
case
|
|
737
|
+
case 2:
|
|
402
738
|
return m.agents()
|
|
403
|
-
case 4:
|
|
404
|
-
var b strings.Builder
|
|
405
|
-
b.WriteString("GLOBAL KEY VAULT\nValues are never displayed.\n\n")
|
|
406
|
-
for _, item := range m.dashboard.Secrets {
|
|
407
|
-
fmt.Fprintf(&b, "● %-55s %s\n", clean(item.Name), clean(item.Updated))
|
|
408
|
-
}
|
|
409
|
-
return b.String()
|
|
410
|
-
case 5:
|
|
411
|
-
return "CURATED CAPABILITIES\n\nSkills: /goal, /loop, TDD, debugging, verification, security, release discipline\nMCP: Context7, Playwright, knowledge-graph memory\nScanners: Trivy, Gitleaks, OSV-Scanner, Semgrep"
|
|
412
|
-
case 6:
|
|
413
|
-
if m.logs == "" {
|
|
414
|
-
return "No log snapshot available."
|
|
415
|
-
}
|
|
416
|
-
return clean(m.logs)
|
|
417
739
|
default:
|
|
418
|
-
return
|
|
740
|
+
return m.settings()
|
|
419
741
|
}
|
|
420
742
|
}
|
|
421
743
|
|
|
@@ -425,6 +747,9 @@ func (m model) View() string {
|
|
|
425
747
|
width = 80
|
|
426
748
|
}
|
|
427
749
|
header := lipgloss.NewStyle().Bold(true).Foreground(accent).Render(strings.ToUpper(clean(m.factoryName))) + " " + lipgloss.NewStyle().Foreground(muted).Render(clean(m.purpose))
|
|
750
|
+
if selected := m.selected(); selected != nil {
|
|
751
|
+
header += " " + lipgloss.NewStyle().Foreground(blue).Render("/ "+clean(selected.Name))
|
|
752
|
+
}
|
|
428
753
|
tabValues := []string{}
|
|
429
754
|
for index, value := range tabs {
|
|
430
755
|
style := lipgloss.NewStyle().Padding(0, 1).Foreground(muted)
|
|
@@ -434,12 +759,21 @@ func (m model) View() string {
|
|
|
434
759
|
tabValues = append(tabValues, style.Render(fmt.Sprintf("%d %s", index+1, value)))
|
|
435
760
|
}
|
|
436
761
|
tabLine := lipgloss.JoinHorizontal(lipgloss.Top, tabValues...)
|
|
437
|
-
|
|
438
|
-
if
|
|
439
|
-
|
|
762
|
+
consoleHeight := 0
|
|
763
|
+
if m.commandOutput != "" {
|
|
764
|
+
consoleHeight = 7
|
|
765
|
+
}
|
|
766
|
+
mainHeight := m.height - 8 - consoleHeight
|
|
767
|
+
if mainHeight < 10 {
|
|
768
|
+
mainHeight = 10
|
|
769
|
+
}
|
|
770
|
+
sidebarWidth := 26
|
|
771
|
+
contentWidth := width - sidebarWidth - 5
|
|
772
|
+
if contentWidth < 50 {
|
|
773
|
+
contentWidth = 50
|
|
440
774
|
}
|
|
441
775
|
lines := strings.Split(m.body(), "\n")
|
|
442
|
-
visible :=
|
|
776
|
+
visible := mainHeight - 2
|
|
443
777
|
maxScroll := len(lines) - visible
|
|
444
778
|
if maxScroll < 0 {
|
|
445
779
|
maxScroll = 0
|
|
@@ -452,15 +786,42 @@ func (m model) View() string {
|
|
|
452
786
|
if end > len(lines) {
|
|
453
787
|
end = len(lines)
|
|
454
788
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
789
|
+
sidebar := lipgloss.NewStyle().Width(sidebarWidth).Height(mainHeight).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1).Render(m.sidebar(mainHeight - 2))
|
|
790
|
+
content := lipgloss.NewStyle().Width(contentWidth).Height(mainHeight).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1, 2).Render(strings.Join(lines[scroll:end], "\n"))
|
|
791
|
+
main := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
|
|
792
|
+
console := ""
|
|
793
|
+
if m.commandOutput != "" {
|
|
794
|
+
consoleLines := strings.Split(clean(m.commandOutput), "\n")
|
|
795
|
+
maxConsoleScroll := len(consoleLines) - 4
|
|
796
|
+
if maxConsoleScroll < 0 {
|
|
797
|
+
maxConsoleScroll = 0
|
|
798
|
+
}
|
|
799
|
+
consoleScroll := m.consoleScroll
|
|
800
|
+
if consoleScroll > maxConsoleScroll {
|
|
801
|
+
consoleScroll = maxConsoleScroll
|
|
802
|
+
}
|
|
803
|
+
consoleEnd := consoleScroll + 4
|
|
804
|
+
if consoleEnd > len(consoleLines) {
|
|
805
|
+
consoleEnd = len(consoleLines)
|
|
806
|
+
}
|
|
807
|
+
console = lipgloss.NewStyle().Width(width-2).Height(5).Border(lipgloss.RoundedBorder()).BorderForeground(muted).Padding(0, 1).Render(fmt.Sprintf("CONSOLE · ctrl+↑/↓ scroll · ctrl+l clear [%d-%d/%d]\n%s", consoleScroll+1, consoleEnd, len(consoleLines), strings.Join(consoleLines[consoleScroll:consoleEnd], "\n")))
|
|
808
|
+
}
|
|
809
|
+
prompt := "Press : to enter a Factory command · n submit · i import · ? help · q quit"
|
|
810
|
+
if m.commandMode {
|
|
811
|
+
prompt = lipgloss.NewStyle().Foreground(accent).Bold(true).Render("› ") + clean(m.commandInput) + "█"
|
|
812
|
+
} else if m.loading {
|
|
813
|
+
prompt = "Refreshing Factory state…"
|
|
814
|
+
}
|
|
815
|
+
if m.err != nil && !m.commandMode {
|
|
816
|
+
prompt = lipgloss.NewStyle().Foreground(danger).Render(m.err.Error())
|
|
459
817
|
}
|
|
460
|
-
|
|
461
|
-
|
|
818
|
+
commandLine := lipgloss.NewStyle().Width(width-2).Height(1).Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1).Render(prompt)
|
|
819
|
+
parts := []string{header, tabLine, main}
|
|
820
|
+
if console != "" {
|
|
821
|
+
parts = append(parts, console)
|
|
462
822
|
}
|
|
463
|
-
|
|
823
|
+
parts = append(parts, commandLine)
|
|
824
|
+
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
464
825
|
}
|
|
465
826
|
|
|
466
827
|
func main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "factory-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
},
|
|
33
33
|
"bin": {
|
|
34
34
|
"factory": "bin/factory",
|
|
35
|
-
"factory-ui": "src/tui.js",
|
|
36
35
|
"agent-factory-ceo": "src/ceo.js",
|
|
37
36
|
"agent-factory-control": "src/control-service.js",
|
|
38
37
|
"agent-factory-dashboard": "src/dashboard.js",
|
|
@@ -74,7 +73,6 @@
|
|
|
74
73
|
"@modelcontextprotocol/server-memory": "2026.7.4",
|
|
75
74
|
"@playwright/mcp": "0.0.78",
|
|
76
75
|
"@upstash/context7-mcp": "3.2.3",
|
|
77
|
-
"neo-blessed": "0.2.0",
|
|
78
76
|
"zod": "3.25.76"
|
|
79
77
|
},
|
|
80
78
|
"devDependencies": {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
3
|
+
import { ServiceBusClient } from "@azure/service-bus";
|
|
4
|
+
import { sendMessage } from "./bus.js";
|
|
5
|
+
|
|
6
|
+
const [action, objectiveId, approvalId, ...reasonWords] = process.argv.slice(2);
|
|
7
|
+
const decision = action === "approve" ? "approved" : action === "deny" ? "denied" : null;
|
|
8
|
+
const reason = reasonWords.join(" ").trim();
|
|
9
|
+
if (!decision || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(objectiveId ?? "") || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approvalId ?? "") || !reason) throw new Error("Usage: factory approval approve|deny OBJECTIVE_ID APPROVAL_ID REASON");
|
|
10
|
+
const namespace = process.env.SERVICE_BUS_NAMESPACE;
|
|
11
|
+
if (!namespace) throw new Error("SERVICE_BUS_NAMESPACE is required");
|
|
12
|
+
const client = new ServiceBusClient(namespace.includes(".") ? namespace : `${namespace}.servicebus.windows.net`, new DefaultAzureCredential());
|
|
13
|
+
const sender = client.createSender(process.env.CONTROL_QUEUE ?? "control-events");
|
|
14
|
+
const messageId = `approval-${objectiveId}-${approvalId}-${decision}`.slice(0, 64);
|
|
15
|
+
try { await sendMessage(sender, { type: "approval_decision", objectiveId, approvalId, decision, actor: "local-operator", reason: reason.slice(0, 1000), decidedAt: new Date().toISOString(), messageId }, messageId, objectiveId); }
|
|
16
|
+
finally { await sender.close(); await client.close(); }
|
|
17
|
+
process.stdout.write(`${JSON.stringify({ objectiveId, approvalId, decision })}\n`);
|
package/src/tui.js
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import blessed from "neo-blessed";
|
|
3
|
-
import { createOperator } from "./operator.js";
|
|
4
|
-
|
|
5
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Factory AI UI requires an interactive terminal");
|
|
6
|
-
|
|
7
|
-
const operator = createOperator();
|
|
8
|
-
const factoryName = process.env.FACTORY_NAME ?? "Factory AI";
|
|
9
|
-
const factoryPurpose = process.env.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
|
|
10
|
-
const screen = blessed.screen({ smartCSR: true, title: factoryName, fullUnicode: true, dockBorders: true });
|
|
11
|
-
const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e0e8", muted: "#7f8b99", accent: "#78dba9", warn: "#efc46b", danger: "#ef7d7d", blue: "#77a8ff" };
|
|
12
|
-
|
|
13
|
-
blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: ` {bold}{#78dba9-fg}${factoryName.toUpperCase()}{/} {/bold} ${factoryPurpose}` });
|
|
14
|
-
const menu = blessed.list({ parent: screen, top: 3, left: 0, width: 23, bottom: 2, border: { type: "line" }, label: " Navigate ", keys: true, mouse: true, vi: true, items: ["Overview", "Workspaces", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"], style: { bg: colors.panel, fg: colors.text, border: { fg: colors.border }, selected: { bg: colors.accent, fg: "#07130d", bold: true }, item: { fg: colors.text } } });
|
|
15
|
-
const main = blessed.box({ parent: screen, top: 3, left: 23, right: 0, bottom: 2, border: { type: "line" }, label: " Overview ", tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true, vi: true, scrollbar: { ch: "▐", style: { fg: colors.accent } }, padding: { left: 2, right: 2 }, style: { bg: colors.bg, fg: colors.text, border: { fg: colors.border } } });
|
|
16
|
-
const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new {bold}i{/} import workspace {bold}r{/} refresh {bold}a{/} secret {bold}y/x{/} approve/deny {bold}q{/} quit" });
|
|
17
|
-
|
|
18
|
-
let dashboard;
|
|
19
|
-
let capabilities;
|
|
20
|
-
let secrets;
|
|
21
|
-
let logs;
|
|
22
|
-
let workspaces = [];
|
|
23
|
-
let section = "Overview";
|
|
24
|
-
let refreshing = false;
|
|
25
|
-
|
|
26
|
-
function safe(value) {
|
|
27
|
-
const text = String(value ?? "").replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
|
|
28
|
-
return blessed.escape ? blessed.escape(text) : text.replaceAll("{", "\\{").replaceAll("}", "\\}");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function status(message, color = colors.muted) {
|
|
32
|
-
footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}i{/} import {bold}r{/} refresh {bold}q{/} quit`);
|
|
33
|
-
screen.render();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function badge(value) {
|
|
37
|
-
const color = value === "succeeded" || value === "complete" ? colors.accent : value === "failed" || value === "blocked" ? colors.danger : value === "running" ? colors.blue : colors.warn;
|
|
38
|
-
return `{${color}-fg}${value}{/}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function renderOverview() {
|
|
42
|
-
const cost = dashboard?.cost ? `${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)}` : "unavailable";
|
|
43
|
-
const counts = Object.entries(dashboard?.summary?.objectives ?? {}).map(([key, value]) => `${key} ${value}`).join(" ") || "none";
|
|
44
|
-
const modelUsage = Object.values(dashboard?.modelUsage ?? {});
|
|
45
|
-
const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
|
|
46
|
-
const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
|
|
47
|
-
const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
|
|
48
|
-
const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${safe(objective.objective)}{/}\n ${safe(objective.repository ?? "")}`).join("\n\n");
|
|
49
|
-
main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n Tokens ${inputTokens} in · ${cachedTokens} cached · ${outputTokens} out\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function renderObjectives() {
|
|
53
|
-
main.setContent((dashboard?.objectives ?? []).slice().reverse().map((objective) => {
|
|
54
|
-
const tasks = objective.tasks.map((task) => ` ${badge((task.stale ? "stale" : task.state).padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${safe(task.model)}{/}\n ${safe(task.title)}${task.activity ? ` · ${safe(task.activity.type)}${task.activity.tool ? ` ${safe(task.activity.tool)}` : ""}` : ""}`).join("\n");
|
|
55
|
-
return `${badge(objective.status)} {bold}${safe(objective.objective)}{/}\n ${safe(objective.id)}\n${tasks}${objective.pullRequest ? `\n PR ${safe(objective.pullRequest)}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${safe(objective.blocker)}{/}` : ""}`;
|
|
56
|
-
}).join("\n\n────────────────────────────────────────────────────────\n\n") || "No objectives." );
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function renderAgents() {
|
|
60
|
-
const agents = (dashboard?.objectives ?? []).flatMap((objective) => objective.tasks.map((task) => ({ objective: objective.objective, ...task }))).filter((task) => !["succeeded"].includes(task.state));
|
|
61
|
-
main.setContent(agents.map((task) => `${badge((task.stale ? "stale" : task.state).padEnd(9))} {bold}${safe(task.role)}{/} ${safe(task.model)}\n ${safe(task.title)}\n ${task.activity ? `${safe(task.activity.phase ?? task.activity.type)}${task.activity.tool ? ` · ${safe(task.activity.tool)}` : ""} · ${Math.round(task.activityAgeSeconds ?? 0)}s ago${task.retries ? ` · ${task.retries} retries` : ""}` : "No activity event yet"}${task.lastError ? `\n {#ef7d7d-fg}${safe(task.lastError)}{/}` : ""}\n {#7f8b99-fg}${safe(task.objective)}{/}`).join("\n\n") || "No active agents.");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function renderSecrets() {
|
|
65
|
-
main.setContent(`{bold}Global Azure Key Vault{/}\n{#7f8b99-fg}Values are never displayed. Press a to add or rotate a secret.{/}\n\n${(secrets ?? []).map((item) => ` {#78dba9-fg}●{/} ${item.name.padEnd(55)} ${item.updated ?? ""}`).join("\n") || " Loading or empty."}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function renderCapabilities() {
|
|
69
|
-
const skills = Object.entries(capabilities?.skills ?? {}).map(([name, item]) => ` {#77a8ff-fg}skill{/} ${name.padEnd(36)} ${item.roles.join(", ")}`).join("\n");
|
|
70
|
-
const mcp = Object.entries(capabilities?.mcp ?? {}).map(([name, item]) => ` {#78dba9-fg}mcp{/} ${name.padEnd(36)} ${item.roles.join(", ")}`).join("\n");
|
|
71
|
-
main.setContent(`{bold}Skills{/}\n\n${skills}\n\n{bold}MCP servers{/}\n\n${mcp}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function renderSettings() {
|
|
75
|
-
const config = operator.config();
|
|
76
|
-
main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n Operator state ${config.storageAccount || "Run Command fallback"}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.5\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function renderWorkspaces() {
|
|
80
|
-
main.setContent(`{bold}Imported workspaces{/}\n{#7f8b99-fg}Press i to import a local path or owner/repo. Press n to submit work by name.{/}\n\n${workspaces.map((workspace) => ` {#78dba9-fg}●{/} {bold}${safe(workspace.name)}{/} ${safe(workspace.repository)}\n ${safe(workspace.localPath ?? "remote only")} · ${safe(workspace.baseBranch)}`).join("\n\n") || " No workspaces imported."}`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function render() {
|
|
84
|
-
main.setLabel(` ${section} `);
|
|
85
|
-
if (section === "Overview") renderOverview();
|
|
86
|
-
else if (section === "Workspaces") renderWorkspaces();
|
|
87
|
-
else if (section === "Objectives") renderObjectives();
|
|
88
|
-
else if (section === "Agents") renderAgents();
|
|
89
|
-
else if (section === "Secrets") renderSecrets();
|
|
90
|
-
else if (section === "Capabilities") renderCapabilities();
|
|
91
|
-
else if (section === "Logs") main.setContent(safe(logs ?? "Loading logs..."));
|
|
92
|
-
else renderSettings();
|
|
93
|
-
main.setScrollPerc(0);
|
|
94
|
-
screen.render();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function refresh() {
|
|
98
|
-
if (refreshing) return;
|
|
99
|
-
refreshing = true;
|
|
100
|
-
status("Refreshing...");
|
|
101
|
-
try {
|
|
102
|
-
dashboard = await operator.dashboard();
|
|
103
|
-
workspaces = await operator.listWorkspaces();
|
|
104
|
-
if (section === "Capabilities" && !capabilities) capabilities = await operator.capabilities();
|
|
105
|
-
if (section === "Secrets") secrets = dashboard?.secrets ?? await operator.listSecrets();
|
|
106
|
-
if (section === "Logs") logs = await operator.logs();
|
|
107
|
-
render();
|
|
108
|
-
status(`Updated ${new Date().toLocaleTimeString()}`);
|
|
109
|
-
} catch (error) { status(error.message, colors.danger); }
|
|
110
|
-
finally { refreshing = false; }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function ask(label, { secret = false } = {}) {
|
|
114
|
-
return new Promise((resolve) => {
|
|
115
|
-
const prompt = blessed.textbox({ parent: screen, top: "center", left: "center", width: "70%", height: 5, border: { type: "line" }, label: ` ${label} `, inputOnFocus: true, keys: true, mouse: true, censor: secret, style: { bg: colors.panel, fg: colors.text, border: { fg: colors.accent } } });
|
|
116
|
-
prompt.focus();
|
|
117
|
-
prompt.readInput((_, value) => { prompt.destroy(); screen.render(); resolve(value?.trim() ?? ""); });
|
|
118
|
-
screen.render();
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function submitObjective() {
|
|
123
|
-
const repository = await ask(`Workspace name${workspaces.length ? ` (${workspaces.map((item) => item.name).join(", ")})` : " or owner/repo"}`);
|
|
124
|
-
if (!repository) return;
|
|
125
|
-
const objective = await ask("CEO objective");
|
|
126
|
-
if (!objective) return;
|
|
127
|
-
status("Submitting objective...");
|
|
128
|
-
try { const result = await operator.submit(repository, objective); status(`Queued ${result.objectiveId ?? "objective"}`, colors.accent); await refresh(); } catch (error) { status(error.message, colors.danger); }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function importWorkspace() {
|
|
132
|
-
const source = await ask("Local path or GitHub owner/repo"); if (!source) return;
|
|
133
|
-
const name = await ask("Workspace name (Enter for repository name)");
|
|
134
|
-
try { const workspace = await operator.importWorkspace(source, name); workspaces = await operator.listWorkspaces(); status(`Imported ${workspace.name}`, colors.accent); section = "Workspaces"; render(); } catch (error) { status(error.message, colors.danger); }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function addSecret() {
|
|
138
|
-
if (section !== "Secrets") return status("Open Secrets before adding a key", colors.warn);
|
|
139
|
-
const name = await ask("Secret name");
|
|
140
|
-
if (!name) return;
|
|
141
|
-
const value = await ask("Secret value", { secret: true });
|
|
142
|
-
if (!value) return;
|
|
143
|
-
status("Storing secret...");
|
|
144
|
-
try {
|
|
145
|
-
await operator.setSecret(name, value);
|
|
146
|
-
secrets = [...(secrets ?? []).filter((item) => item.name !== name), { name, updated: new Date().toISOString() }].sort((left, right) => left.name.localeCompare(right.name));
|
|
147
|
-
renderSecrets();
|
|
148
|
-
status(`Stored ${name}`, colors.accent);
|
|
149
|
-
} catch (error) { status(error.message, colors.danger); }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function decideApproval(decision) {
|
|
153
|
-
const objectiveId = await ask("Objective ID"); if (!objectiveId) return;
|
|
154
|
-
const approvalId = await ask("Approval ID"); if (!approvalId) return;
|
|
155
|
-
const reason = await ask(`${decision === "approved" ? "Approval" : "Denial"} reason`); if (!reason) return;
|
|
156
|
-
try { await operator.approval({ objectiveId, approvalId, decision, reason }); status(`Approval ${decision}`, decision === "approved" ? colors.accent : colors.danger); await refresh(); } catch (error) { status(error.message, colors.danger); }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
menu.on("select", async (_, index) => { section = menu.getItem(index).getText(); render(); await refresh(); menu.focus(); });
|
|
160
|
-
screen.key(["q", "C-c"], () => process.exit(0));
|
|
161
|
-
screen.key("r", refresh);
|
|
162
|
-
screen.key("n", submitObjective);
|
|
163
|
-
screen.key("i", importWorkspace);
|
|
164
|
-
screen.key("a", addSecret);
|
|
165
|
-
screen.key("y", () => decideApproval("approved"));
|
|
166
|
-
screen.key("x", () => decideApproval("denied"));
|
|
167
|
-
screen.key("p", async () => { status("Pausing workers..."); try { await operator.control("pause"); status("Workers paused", colors.warn); } catch (error) { status(error.message, colors.danger); } });
|
|
168
|
-
screen.key("u", async () => { status("Resuming workers..."); try { await operator.control("resume"); status("Workers active", colors.accent); } catch (error) { status(error.message, colors.danger); } });
|
|
169
|
-
screen.key("tab", () => menu.focus());
|
|
170
|
-
menu.focus();
|
|
171
|
-
screen.render();
|
|
172
|
-
await refresh();
|
|
173
|
-
setInterval(refresh, 30_000).unref();
|