factory-ai 1.6.1 → 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,6 +4,13 @@ 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
+
7
14
  ## [1.6.1] - 2026-07-13
8
15
 
9
16
  ### Added
@@ -14,7 +21,7 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
14
21
 
15
22
  ### Added
16
23
 
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.
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.
18
25
 
19
26
  ## [1.5.3] - 2026-07-13
20
27
 
@@ -38,7 +45,7 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
38
45
 
39
46
  ### Added
40
47
 
41
- - 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.
42
49
  - Automatic Azure and Bedrock context compaction with immutable objective retention and recent tool evidence.
43
50
  - Hierarchical `AGENTS.md` discovery, preconfigured project instructions, and bounded `.agent-factory` context.
44
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
@@ -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`, 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
+
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"
@@ -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.1",
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();