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 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 workspace views in both TUIs.
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 safe Node action fallback.
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 'Charm client unavailable for this release; using Node fallback.\n' >&2
240
- FACTORY_RESOURCE_GROUP="$RESOURCE_GROUP" FACTORY_VM="$VM" FACTORY_SERVICE_BUS="$NAMESPACE" FACTORY_KEY_VAULT="$VAULT" FACTORY_STORAGE_ACCOUNT="$STORAGE" FACTORY_NAME="$FACTORY_NAME" FACTORY_PURPOSE="$FACTORY_PURPOSE" node "$FACTORY_ROOT/src/tui.js"
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
@@ -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 resumeMsg struct{ err error }
108
+ type commandResultMsg struct {
109
+ command, output string
110
+ err error
111
+ }
100
112
 
101
113
  type model struct {
102
- client *azblob.Client
103
- factoryName string
104
- purpose string
105
- width int
106
- height int
107
- tab int
108
- scroll int
109
- dashboard dashboard
110
- logs string
111
- workspaces []workspace
112
- workspaceErr string
113
- loading bool
114
- err error
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{"Overview", "Workspaces", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"}
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 "o", "n", "i", "a", "p", "u", "y", "x":
237
- return m, tea.ExecProcess(exec.Command("factory-ui"), func(err error) tea.Msg { return resumeMsg{err: err} })
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] <= '8' {
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 resumeMsg:
256
- m.err = msg.err
257
- if msg.err == nil {
258
- m.loading = true
259
- return m, fetchCmd(m.client)
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 m.dashboard.Summary.Objectives {
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(m.dashboard.Objectives) - 1; index >= 0 && index >= len(m.dashboard.Objectives)-8; index-- {
311
- item := m.dashboard.Objectives[index]
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
- for index := len(m.dashboard.Objectives) - 1; index >= 0; index-- {
320
- item := m.dashboard.Objectives[index]
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.dashboard.Objectives {
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) workspaceList() string {
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
- return "Workspace catalog unavailable: " + clean(m.workspaceErr)
671
+ value.WriteString(lipgloss.NewStyle().Foreground(danger).Render("Catalog unavailable") + "\n")
381
672
  }
382
673
  if len(m.workspaces) == 0 {
383
- return "No workspaces imported. Press o, then i, to import a local path or owner/repo."
674
+ value.WriteString("No workspaces\n\nPress i to import")
384
675
  }
385
- var value strings.Builder
386
- value.WriteString("IMPORTED WORKSPACES\n\n")
387
- for _, item := range m.workspaces {
388
- fmt.Fprintf(&value, "%s %s\n %s · %s\n\n", lipgloss.NewStyle().Foreground(accent).Bold(true).Render(clean(item.Name)), clean(item.Repository), clean(item.LocalPath), clean(item.BaseBranch))
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 3:
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 fmt.Sprintf("FACTORY SETTINGS\n\nName %s\nPurpose %s\nStorage Azure Blob snapshot\nRefresh 15 seconds\n\nUse `factory configure models` or `factory models set ROLE PROVIDER/MODEL` to change routing.", clean(m.factoryName), clean(m.purpose))
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
- height := m.height - 6
438
- if height < 10 {
439
- height = 10
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 := height - 2
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
- content := lipgloss.NewStyle().Width(width-4).Height(height).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1, 2).Render(strings.Join(lines[scroll:end], "\n"))
456
- footer := "←/→ tabs · ↑/↓ scroll · o actions · r refresh · q quit"
457
- if m.loading {
458
- footer = "Refreshing snapshot…"
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
- if m.err != nil {
461
- footer = lipgloss.NewStyle().Foreground(danger).Render(m.err.Error())
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
- return lipgloss.JoinVertical(lipgloss.Left, header, tabLine, content, lipgloss.NewStyle().Foreground(muted).Render(footer))
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.6.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();