factory-ai 1.6.0 → 2.0.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,11 +4,24 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.0] - 2026-07-13
8
+
9
+ ### Changed
10
+
11
+ - 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.
12
+ - Removed the `factory-ui` JavaScript binary and `neo-blessed` dependency; `factory ui` now fails closed if its verified attested native binary is unavailable.
13
+
14
+ ## [1.6.1] - 2026-07-13
15
+
16
+ ### Added
17
+
18
+ - `factory update check|now|status|enable|disable` for local version checks, immediate verified Azure/local updates, and explicit six-hour timer control.
19
+
7
20
  ## [1.6.0] - 2026-07-13
8
21
 
9
22
  ### Added
10
23
 
11
- - 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.
24
+ - 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.
12
25
 
13
26
  ## [1.5.3] - 2026-07-13
14
27
 
@@ -32,7 +45,7 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
32
45
 
33
46
  ### Added
34
47
 
35
- - Cross-platform Bubble Tea/Lip Gloss operator client with direct Azure Blob snapshots, verified release binaries, scrolling, and safe Node action fallback.
48
+ - Cross-platform Bubble Tea/Lip Gloss operator client with direct Azure Blob snapshots, verified release binaries, scrolling, and native command execution.
36
49
  - Automatic Azure and Bedrock context compaction with immutable objective retention and recent tool evidence.
37
50
  - Hierarchical `AGENTS.md` discovery, preconfigured project instructions, and bounded `.agent-factory` context.
38
51
  - Durable worktree crash recovery, parallel read-only tool batches, live activity timelines, retry/error telemetry, and stale-agent watchdog recovery.
package/README.md CHANGED
@@ -74,6 +74,10 @@ factory ui
74
74
  | `factory workspace show NAME` | Inspect one workspace |
75
75
  | `factory workspace remove NAME` | Remove a catalog entry without deleting repository files |
76
76
  | `factory submit WORKSPACE "OBJECTIVE"` | Send one CEO objective using a workspace name; owner/repo remains supported |
77
+ | `factory update check` | Compare the installed CLI with the latest stable npm release |
78
+ | `factory update now` | Run verified Azure updater immediately and update the local CLI |
79
+ | `factory update status` | Show automatic-update timer and deployed runtime version |
80
+ | `factory update enable\|disable` | Control the six-hour verified automatic-update timer |
77
81
  | `factory issue OWNER/REPO NUMBER` | Turn a GitHub issue into a tracked objective |
78
82
  | `factory telegram configure` | Configure allowlisted Telegram remote intake |
79
83
  | `factory dashboard` | Objectives, agents, models, queue, DLQ, and Azure cost |
@@ -92,6 +96,8 @@ factory ui
92
96
  | `factory secret set NAME` | Store a credential in global Key Vault |
93
97
  | `factory github connect ORG` | Connect GitHub Enterprise credentials |
94
98
 
99
+ Inside `factory ui`, press `:` to enter any Factory command without the `factory` prefix. Use `↑/↓` for command history. Shortcuts: `n` submit, `i` import workspace, `a` set secret, `y/x` approve or deny, `p/u` pause or resume, `?` help, and `q` quit. Command output and errors remain visible inside the native TUI.
100
+
95
101
  ## Architecture
96
102
 
97
103
  ```mermaid
package/bin/factory CHANGED
@@ -30,6 +30,8 @@ usage() {
30
30
  ' telegram status | configure | start | stop' \
31
31
  ' workspace list | import PATH|OWNER/REPO [--name NAME] | show NAME | remove NAME' \
32
32
  ' submit <workspace|owner/repo> <objective>' \
33
+ ' update check | now | status | enable | disable' \
34
+ ' approval approve | deny OBJECTIVE_ID APPROVAL_ID REASON' \
33
35
  ' issue <owner/repo> <number>' \
34
36
  ' ui' \
35
37
  ' init <local-project-path>' \
@@ -46,7 +48,7 @@ apply_runtime_model() {
46
48
  script="set -euo pipefail; exec 9>/run/lock/factory-ai-config.lock; flock -x 9; first=/etc/agent-factory.env; second=/etc/agent-factory-control.env; backup_first=\$(mktemp); backup_second=\$(mktemp); cp --preserve=mode,ownership \"\$first\" \"\$backup_first\"; cp --preserve=mode,ownership \"\$second\" \"\$backup_second\"; rollback() { install -m 0600 -o root -g root \"\$backup_first\" \"\$first\"; install -m 0600 -o root -g root \"\$backup_second\" \"\$second\"; systemctl restart agent-factory-control agent-factory-worker agent-factory-release || true; rm -f \"\$backup_first\" \"\$backup_second\"; }; trap rollback ERR TERM INT; for file in \"\$first\" \"\$second\"; do tmp=\$(mktemp); grep -v '^${variable}=' \"\$file\" > \"\$tmp\" || true; if [[ '$mode' == set ]]; then printf '${variable}=%s\\n' '$value' >> \"\$tmp\"; fi; install -m 0600 -o root -g root \"\$tmp\" \"\$file\"; rm -f \"\$tmp\"; done; systemctl restart agent-factory-control agent-factory-worker agent-factory-release; systemctl is-active --quiet agent-factory-control agent-factory-worker agent-factory-release; trap - ERR TERM INT; rm -f \"\$backup_first\" \"\$backup_second\""
47
49
  printf -v script 'bash -c %q' "$script"
48
50
  settings=$(jq -n --arg command "$script" '{commandToExecute:$command}')
49
- az vm extension set --resource-group "$RESOURCE_GROUP" --vm-name "$VM" --name FactoryAIConfigure \
51
+ az vm extension set --resource-group "$RESOURCE_GROUP" --vm-name "$VM" --name CustomScript \
50
52
  --publisher Microsoft.Azure.Extensions --version 2.1 --force-update --protected-settings "$settings" --output none
51
53
  }
52
54
 
@@ -76,7 +78,46 @@ configure_model_transaction() {
76
78
  trap - EXIT INT TERM
77
79
  }
78
80
 
81
+ vm_extension_command() {
82
+ local script=$1 settings
83
+ printf -v script 'bash -c %q' "$script"
84
+ settings=$(jq -n --arg command "$script" '{commandToExecute:$command}')
85
+ az vm extension set --resource-group "$RESOURCE_GROUP" --vm-name "$VM" --name CustomScript \
86
+ --publisher Microsoft.Azure.Extensions --version 2.1 --force-update --protected-settings "$settings" --output none
87
+ az vm extension show --resource-group "$RESOURCE_GROUP" --vm-name "$VM" --name CustomScript --instance-view --query 'instanceView.statuses[0].message' --output tsv
88
+ }
89
+
79
90
  case "$command" in
91
+ update)
92
+ action=${1:-check}
93
+ case "$action" in
94
+ check)
95
+ current=$(node -p "require('$FACTORY_ROOT/package.json').version")
96
+ latest=$(npm view factory-ai version)
97
+ printf 'Local %s · latest %s\n' "$current" "$latest"
98
+ ;;
99
+ now)
100
+ vm_extension_command 'bash /opt/agent-factory/app/bootstrap/auto-update.sh'
101
+ latest=$(npm view factory-ai version)
102
+ current=$(node -p "require('$FACTORY_ROOT/package.json').version")
103
+ if [[ $current != "$latest" ]]; then npm install --global "factory-ai@$latest"; fi
104
+ printf 'Factory AI is current at %s locally and Azure update verification completed.\n' "$latest"
105
+ ;;
106
+ status)
107
+ vm_extension_command 'set -e; printf "timer enabled: "; systemctl is-enabled factory-ai-update.timer; printf "timer active: "; systemctl is-active factory-ai-update.timer; jq -c . /opt/agent-factory/state/runtime-version.json'
108
+ ;;
109
+ enable)
110
+ vm_extension_command 'systemctl enable --now factory-ai-update.timer; systemctl is-active factory-ai-update.timer'
111
+ ;;
112
+ disable)
113
+ vm_extension_command 'systemctl disable --now factory-ai-update.timer; systemctl is-active factory-ai-update.timer >/dev/null && exit 1 || true; printf "automatic updates disabled\n"'
114
+ ;;
115
+ *) printf 'Usage: factory update check | now | status | enable | disable\n' >&2; exit 2 ;;
116
+ esac
117
+ ;;
118
+ approval)
119
+ SERVICE_BUS_NAMESPACE="$NAMESPACE" CONTROL_QUEUE=control-events exec node "$FACTORY_ROOT/src/approval-cli.js" "$@"
120
+ ;;
80
121
  acp)
81
122
  request=${1:?ACP request JSON file required}
82
123
  SERVICE_BUS_NAMESPACE="$NAMESPACE" CONTROL_QUEUE=control-events FACTORY_ACP_ENABLED=true exec node "$FACTORY_ROOT/src/acp-cli.js" "$request"
@@ -199,8 +240,8 @@ case "$command" in
199
240
  if [[ -x $binary ]]; then
200
241
  FACTORY_STORAGE_ACCOUNT="$STORAGE" FACTORY_NAME="$FACTORY_NAME" FACTORY_PURPOSE="$FACTORY_PURPOSE" exec "$binary"
201
242
  fi
202
- printf 'Charm client unavailable for this release; using Node fallback.\n' >&2
203
- 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
204
245
  ;;
205
246
  setup)
206
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"
@@ -11,6 +12,7 @@ import (
11
12
  "regexp"
12
13
  "strconv"
13
14
  "strings"
15
+ "sync"
14
16
  "time"
15
17
 
16
18
  "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
@@ -48,6 +50,12 @@ type objective struct {
48
50
  Tasks []task `json:"tasks"`
49
51
  PullRequest string `json:"pullRequest"`
50
52
  Blocker string `json:"blocker"`
53
+ Approval *struct {
54
+ ApprovalID string `json:"approvalId"`
55
+ Status string `json:"status"`
56
+ Policy string `json:"policy"`
57
+ Reason string `json:"reason"`
58
+ } `json:"approval"`
51
59
  }
52
60
 
53
61
  type workspace struct {
@@ -96,22 +104,30 @@ type snapshotMsg struct {
96
104
  err error
97
105
  }
98
106
  type tickMsg time.Time
99
- type resumeMsg struct{ err error }
107
+ type commandResultMsg struct {
108
+ command, output string
109
+ err error
110
+ }
100
111
 
101
112
  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
113
+ client *azblob.Client
114
+ factoryName string
115
+ purpose string
116
+ width int
117
+ height int
118
+ tab int
119
+ scroll int
120
+ dashboard dashboard
121
+ logs string
122
+ workspaces []workspace
123
+ workspaceErr string
124
+ loading bool
125
+ err error
126
+ commandMode bool
127
+ commandInput string
128
+ commandOutput string
129
+ commandHistory []string
130
+ historyIndex int
115
131
  }
116
132
 
117
133
  var (
@@ -126,6 +142,22 @@ var (
126
142
  ansi = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)`)
127
143
  )
128
144
 
145
+ type synchronizedBuffer struct {
146
+ mu sync.Mutex
147
+ buffer bytes.Buffer
148
+ }
149
+
150
+ func (value *synchronizedBuffer) Write(data []byte) (int, error) {
151
+ value.mu.Lock()
152
+ defer value.mu.Unlock()
153
+ return value.buffer.Write(data)
154
+ }
155
+ func (value *synchronizedBuffer) String() string {
156
+ value.mu.Lock()
157
+ defer value.mu.Unlock()
158
+ return value.buffer.String()
159
+ }
160
+
129
161
  func clean(value string) string {
130
162
  value = ansi.ReplaceAllString(value, "")
131
163
  return strings.Map(func(character rune) rune {
@@ -136,6 +168,96 @@ func clean(value string) string {
136
168
  }, value)
137
169
  }
138
170
 
171
+ func parseCommandLine(value string) ([]string, error) {
172
+ var args []string
173
+ var current strings.Builder
174
+ var quote rune
175
+ escaped := false
176
+ flush := func() {
177
+ if current.Len() > 0 {
178
+ args = append(args, current.String())
179
+ current.Reset()
180
+ }
181
+ }
182
+ for _, character := range strings.TrimSpace(value) {
183
+ if escaped {
184
+ current.WriteRune(character)
185
+ escaped = false
186
+ continue
187
+ }
188
+ if character == '\\' && quote != '\'' {
189
+ escaped = true
190
+ continue
191
+ }
192
+ if quote != 0 {
193
+ if character == quote {
194
+ quote = 0
195
+ } else {
196
+ current.WriteRune(character)
197
+ }
198
+ continue
199
+ }
200
+ if character == '\'' || character == '"' {
201
+ quote = character
202
+ continue
203
+ }
204
+ if character == ' ' || character == '\t' {
205
+ flush()
206
+ continue
207
+ }
208
+ current.WriteRune(character)
209
+ }
210
+ if escaped || quote != 0 {
211
+ return nil, fmt.Errorf("unterminated quote or escape")
212
+ }
213
+ flush()
214
+ if len(args) > 0 && args[0] == "factory" {
215
+ args = args[1:]
216
+ }
217
+ return args, nil
218
+ }
219
+
220
+ func validateFactoryCommand(args []string) error {
221
+ if len(args) == 0 {
222
+ return fmt.Errorf("enter a factory command")
223
+ }
224
+ 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}
225
+ if args[0] == "ui" {
226
+ return fmt.Errorf("the UI command cannot be launched inside itself")
227
+ }
228
+ if !allowed[args[0]] {
229
+ return fmt.Errorf("unknown factory command: %s", args[0])
230
+ }
231
+ return nil
232
+ }
233
+
234
+ func interactiveFactoryCommand(args []string) bool {
235
+ if len(args) == 0 {
236
+ return false
237
+ }
238
+ 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"
239
+ }
240
+
241
+ func executeFactoryCommand(args []string) tea.Cmd {
242
+ return func() tea.Msg {
243
+ command := "factory " + strings.Join(args, " ")
244
+ output, err := exec.Command("factory", args...).CombinedOutput()
245
+ return commandResultMsg{command: command, output: string(output), err: err}
246
+ }
247
+ }
248
+
249
+ func interactiveFactoryProcess(args []string) tea.Cmd {
250
+ var transcript synchronizedBuffer
251
+ command := exec.Command("factory", args...)
252
+ command.Stdin = os.Stdin
253
+ command.Stdout = io.MultiWriter(os.Stdout, &transcript)
254
+ command.Stderr = io.MultiWriter(os.Stderr, &transcript)
255
+ label := "factory " + strings.Join(args, " ")
256
+ return tea.ExecProcess(command, func(err error) tea.Msg {
257
+ return commandResultMsg{command: label, output: transcript.String(), err: err}
258
+ })
259
+ }
260
+
139
261
  func readConfig() map[string]string {
140
262
  result := map[string]string{}
141
263
  for _, key := range []string{"FACTORY_STORAGE_ACCOUNT", "FACTORY_NAME", "FACTORY_PURPOSE"} {
@@ -203,6 +325,57 @@ func (m model) Init() tea.Cmd { return tea.Batch(fetchCmd(m.client), tickCmd())
203
325
  func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
204
326
  switch msg := message.(type) {
205
327
  case tea.KeyMsg:
328
+ if m.commandMode {
329
+ switch msg.String() {
330
+ case "esc":
331
+ m.commandMode = false
332
+ m.commandInput = ""
333
+ case "enter":
334
+ line := strings.TrimSpace(m.commandInput)
335
+ args, err := parseCommandLine(line)
336
+ if err == nil {
337
+ err = validateFactoryCommand(args)
338
+ }
339
+ if err != nil {
340
+ m.commandOutput = err.Error()
341
+ return m, nil
342
+ }
343
+ m.commandMode = false
344
+ m.commandInput = ""
345
+ m.commandHistory = append(m.commandHistory, line)
346
+ m.historyIndex = len(m.commandHistory)
347
+ if interactiveFactoryCommand(args) {
348
+ return m, interactiveFactoryProcess(args)
349
+ }
350
+ m.loading = true
351
+ return m, executeFactoryCommand(args)
352
+ case "backspace", "ctrl+h":
353
+ runes := []rune(m.commandInput)
354
+ if len(runes) > 0 {
355
+ m.commandInput = string(runes[:len(runes)-1])
356
+ }
357
+ case "ctrl+u":
358
+ m.commandInput = ""
359
+ case "up":
360
+ if m.historyIndex > 0 {
361
+ m.historyIndex--
362
+ m.commandInput = m.commandHistory[m.historyIndex]
363
+ }
364
+ case "down":
365
+ if m.historyIndex < len(m.commandHistory)-1 {
366
+ m.historyIndex++
367
+ m.commandInput = m.commandHistory[m.historyIndex]
368
+ } else {
369
+ m.historyIndex = len(m.commandHistory)
370
+ m.commandInput = ""
371
+ }
372
+ default:
373
+ if len(msg.Runes) > 0 {
374
+ m.commandInput += string(msg.Runes)
375
+ }
376
+ }
377
+ return m, nil
378
+ }
206
379
  switch msg.String() {
207
380
  case "q", "ctrl+c":
208
381
  return m, tea.Quit
@@ -233,8 +406,39 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
233
406
  m.scroll += 10
234
407
  case "home":
235
408
  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} })
409
+ case ":", "o":
410
+ m.commandMode = true
411
+ m.commandInput = ""
412
+ m.historyIndex = len(m.commandHistory)
413
+ case "n":
414
+ m.commandMode = true
415
+ m.commandInput = "submit "
416
+ m.historyIndex = len(m.commandHistory)
417
+ case "i":
418
+ m.commandMode = true
419
+ m.commandInput = "workspace import "
420
+ m.historyIndex = len(m.commandHistory)
421
+ case "a":
422
+ m.commandMode = true
423
+ m.commandInput = "secret set "
424
+ m.historyIndex = len(m.commandHistory)
425
+ case "y":
426
+ m.commandMode = true
427
+ m.commandInput = "approval approve "
428
+ m.historyIndex = len(m.commandHistory)
429
+ case "x":
430
+ m.commandMode = true
431
+ m.commandInput = "approval deny "
432
+ m.historyIndex = len(m.commandHistory)
433
+ case "p":
434
+ m.loading = true
435
+ return m, executeFactoryCommand([]string{"pause"})
436
+ case "u":
437
+ m.loading = true
438
+ return m, executeFactoryCommand([]string{"resume"})
439
+ case "?":
440
+ m.loading = true
441
+ return m, executeFactoryCommand([]string{"--help"})
238
442
  case "r":
239
443
  m.loading = true
240
444
  return m, fetchCmd(m.client)
@@ -252,12 +456,16 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
252
456
  if msg.err == nil {
253
457
  m.dashboard, m.logs, m.workspaces, m.workspaceErr = msg.dashboard, msg.logs, msg.workspaces, msg.workspaceErr
254
458
  }
255
- case resumeMsg:
459
+ case commandResultMsg:
460
+ m.loading = false
256
461
  m.err = msg.err
257
- if msg.err == nil {
258
- m.loading = true
259
- return m, fetchCmd(m.client)
462
+ result := strings.TrimSpace(clean(msg.output))
463
+ if msg.err != nil && result == "" {
464
+ result = msg.err.Error()
260
465
  }
466
+ m.commandOutput = fmt.Sprintf("$ %s\n%s", clean(msg.command), result)
467
+ m.scroll = int(^uint(0) >> 1)
468
+ return m, fetchCmd(m.client)
261
469
  case tickMsg:
262
470
  if !m.loading {
263
471
  m.loading = true
@@ -336,6 +544,9 @@ func (m model) objectives() string {
336
544
  if item.PullRequest != "" {
337
545
  fmt.Fprintf(&value, " PR %s\n", clean(item.PullRequest))
338
546
  }
547
+ if item.Approval != nil {
548
+ 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))
549
+ }
339
550
  value.WriteString("\n")
340
551
  }
341
552
  return value.String()
@@ -380,7 +591,7 @@ func (m model) workspaceList() string {
380
591
  return "Workspace catalog unavailable: " + clean(m.workspaceErr)
381
592
  }
382
593
  if len(m.workspaces) == 0 {
383
- return "No workspaces imported. Press o, then i, to import a local path or owner/repo."
594
+ return "No workspaces imported. Press i and enter a local path or owner/repo."
384
595
  }
385
596
  var value strings.Builder
386
597
  value.WriteString("IMPORTED WORKSPACES\n\n")
@@ -438,7 +649,11 @@ func (m model) View() string {
438
649
  if height < 10 {
439
650
  height = 10
440
651
  }
441
- lines := strings.Split(m.body(), "\n")
652
+ body := m.body()
653
+ if m.commandOutput != "" {
654
+ body += "\n\nCOMMAND OUTPUT\n" + m.commandOutput
655
+ }
656
+ lines := strings.Split(body, "\n")
442
657
  visible := height - 2
443
658
  maxScroll := len(lines) - visible
444
659
  if maxScroll < 0 {
@@ -453,11 +668,13 @@ func (m model) View() string {
453
668
  end = len(lines)
454
669
  }
455
670
  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 {
671
+ footer := ": command · n submit · i import · ? help · q quit"
672
+ if m.commandMode {
673
+ footer = lipgloss.NewStyle().Foreground(accent).Bold(true).Render(": ") + clean(m.commandInput) + "█"
674
+ } else if m.loading {
458
675
  footer = "Refreshing snapshot…"
459
676
  }
460
- if m.err != nil {
677
+ if m.err != nil && !m.commandMode {
461
678
  footer = lipgloss.NewStyle().Foreground(danger).Render(m.err.Error())
462
679
  }
463
680
  return lipgloss.JoinVertical(lipgloss.Left, header, tabLine, content, lipgloss.NewStyle().Foreground(muted).Render(footer))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "1.6.0",
3
+ "version": "2.0.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();