forgecommandline 0.8.0__py3-none-any.whl
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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/parallel.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Parallel sub-agents. Off unless the user asks: /parallel <task> || <task>, or
|
|
2
|
+
/parallel on to let the main agent split work itself with the run_parallel tool.
|
|
3
|
+
|
|
4
|
+
Each sub-agent gets a copy of the whole conversation so far (so it knows what
|
|
5
|
+
was already done), works on its own task in a thread, and starts its failover
|
|
6
|
+
chain at a different provider so the load spreads across keys. Their reports
|
|
7
|
+
go back into the main conversation, so every later reply knows what they did."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from .ui import UI, console
|
|
19
|
+
|
|
20
|
+
CONFIRM_LOCK = threading.Lock() # one approval prompt on screen at a time
|
|
21
|
+
COLORS = ["#67e8f9", "#fbbf24", "#4ade80", "#f472b6", "#93c5fd", "#fb923c", "#a3e635", "#e879f9"]
|
|
22
|
+
|
|
23
|
+
SCHEMA = {
|
|
24
|
+
"type": "function",
|
|
25
|
+
"function": {
|
|
26
|
+
"name": "run_parallel",
|
|
27
|
+
"description": (
|
|
28
|
+
"Run 2 or more independent tasks at the same time, each by its own sub-agent with a copy of "
|
|
29
|
+
"this conversation. Use only when the work splits into parts that don't depend on each other "
|
|
30
|
+
"and don't edit the same files. You get back each agent's report."),
|
|
31
|
+
"parameters": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"tasks": {
|
|
35
|
+
"type": "array", "minItems": 2,
|
|
36
|
+
"items": {"type": "string", "description": "Full, self-contained instructions for one agent"},
|
|
37
|
+
},
|
|
38
|
+
"isolate": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": ("Give each agent its own git worktree and branch so their edits can't "
|
|
41
|
+
"collide (git projects only). Use when agents might touch the same files."),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"required": ["tasks"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
DELEGATE_SCHEMA = {
|
|
51
|
+
"type": "function",
|
|
52
|
+
"function": {
|
|
53
|
+
"name": "delegate",
|
|
54
|
+
"description": (
|
|
55
|
+
"Hand tasks to team members (other models) that run at the same time, each with a copy of this "
|
|
56
|
+
"conversation. Pick the worker whose strengths fit each task, from the team list in your "
|
|
57
|
+
"instructions. Tasks must be independent and must not edit the same files. You get back each "
|
|
58
|
+
"worker's report; then check, combine, and finish the job."),
|
|
59
|
+
"parameters": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"tasks": {
|
|
63
|
+
"type": "array", "minItems": 1,
|
|
64
|
+
"items": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"task": {"type": "string", "description": "Full, self-contained instructions"},
|
|
68
|
+
"worker": {"type": "string", "description": "Worker id from the team list, e.g. nvidia:minimaxai/minimax-m3.1-flash"},
|
|
69
|
+
"why": {"type": "string", "description": "One short reason this worker fits"},
|
|
70
|
+
},
|
|
71
|
+
"required": ["task", "worker"],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
"required": ["tasks"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_tasks(text: str) -> list[str]:
|
|
82
|
+
return [t.strip() for t in text.split("||") if t.strip()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_tasks(items) -> list[dict]:
|
|
86
|
+
"""Normalise tasks to {task, worker, why}. Accepts plain strings, '@worker task'
|
|
87
|
+
strings, or dicts from the delegate tool."""
|
|
88
|
+
out = []
|
|
89
|
+
for it in items or []:
|
|
90
|
+
if isinstance(it, dict):
|
|
91
|
+
task = str(it.get("task", "")).strip()
|
|
92
|
+
worker, why = str(it.get("worker", "")).strip(), str(it.get("why", "")).strip()
|
|
93
|
+
else:
|
|
94
|
+
task, worker, why = str(it).strip(), "", ""
|
|
95
|
+
if task.startswith("@"):
|
|
96
|
+
head, _, rest = task.partition(" ")
|
|
97
|
+
worker, task = head[1:], rest.strip()
|
|
98
|
+
if task:
|
|
99
|
+
out.append({"task": task, "worker": worker, "why": why})
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SubUI(UI):
|
|
104
|
+
"""Quiet, labelled output for a sub-agent running in a thread."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, parent: UI, n: int):
|
|
107
|
+
super().__init__(parent.verbose)
|
|
108
|
+
self.n = n
|
|
109
|
+
self.color = COLORS[(n - 1) % len(COLORS)]
|
|
110
|
+
self.final = ""
|
|
111
|
+
|
|
112
|
+
def _tag(self) -> Text:
|
|
113
|
+
return Text(f" [{self.n}] ", style=f"bold {self.color}")
|
|
114
|
+
|
|
115
|
+
def start_spinner(self, label: str = "thinking") -> None: ...
|
|
116
|
+
def stop_spinner(self) -> None: ...
|
|
117
|
+
def tool_result(self, text: str) -> None: ...
|
|
118
|
+
|
|
119
|
+
def log(self, msg: str) -> None:
|
|
120
|
+
if self.verbose:
|
|
121
|
+
console.print(self._tag().append(f"↳ {msg}", style="faint"))
|
|
122
|
+
|
|
123
|
+
def tool(self, name: str, summary: str) -> None:
|
|
124
|
+
console.print(self._tag().append(name, style="tool").append(" ").append(summary[:120], style="muted"))
|
|
125
|
+
|
|
126
|
+
def assistant(self, text: str, meta: str = "") -> None:
|
|
127
|
+
self.final = text # shown when the agent finishes
|
|
128
|
+
|
|
129
|
+
def info(self, msg: str) -> None:
|
|
130
|
+
console.print(self._tag().append(msg, style="muted"))
|
|
131
|
+
|
|
132
|
+
def warn(self, msg: str) -> None:
|
|
133
|
+
console.print(self._tag().append(msg, style="warn"))
|
|
134
|
+
|
|
135
|
+
def error(self, msg: str) -> None:
|
|
136
|
+
console.print(self._tag().append("✗ " + msg, style="err"))
|
|
137
|
+
|
|
138
|
+
def confirm(self, title: str, detail: str, why: str = ""):
|
|
139
|
+
with CONFIRM_LOCK:
|
|
140
|
+
return super().confirm(f"[agent {self.n}] {title}", detail, why)
|
|
141
|
+
|
|
142
|
+
def action(self, verb: str, target: str, note: str = "") -> None:
|
|
143
|
+
console.print(self._tag().append(verb, style="tool").append(f"({target[:110]})", style="muted"))
|
|
144
|
+
|
|
145
|
+
def result(self, first: str, body=None) -> None:
|
|
146
|
+
console.print(self._tag().append(" ⎿ " + first, style="faint"))
|
|
147
|
+
|
|
148
|
+
def peek(self, kind: str, target: str) -> None: ...
|
|
149
|
+
def flush(self) -> None: ...
|
|
150
|
+
def failover(self, msg: str) -> None:
|
|
151
|
+
console.print(self._tag().append("⚠ " + msg, style="warn"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _handoff_packet(parent, task: str, reason: str) -> str:
|
|
155
|
+
goal = parent._goal() if hasattr(parent, "_goal") else ""
|
|
156
|
+
if not goal:
|
|
157
|
+
goal = next((str(m.get("content") or "") for m in reversed(parent.messages)
|
|
158
|
+
if m.get("role") == "user"), "")
|
|
159
|
+
lines = [f"Original user goal: {goal}",
|
|
160
|
+
f"Why this task was handed off: {reason or 'independent tasks requested'}"]
|
|
161
|
+
|
|
162
|
+
reads, changes, attempts = [], [], []
|
|
163
|
+
for step in parent.tools.ledger.steps:
|
|
164
|
+
path = str(step.args.get("path", "")).strip()
|
|
165
|
+
if step.name == "read_file" and path:
|
|
166
|
+
reads.append(path)
|
|
167
|
+
elif step.name in ("write_file", "edit_file") and path:
|
|
168
|
+
(changes if step.ok else attempts).append(path)
|
|
169
|
+
if step.name == "run_shell":
|
|
170
|
+
command = str(step.args.get("command", "")).strip()
|
|
171
|
+
if command:
|
|
172
|
+
status = "succeeded" if step.ok else "failed"
|
|
173
|
+
detail = " ".join(step.out.split())[:220]
|
|
174
|
+
attempts.append(f"command {command!r} ({status})" +
|
|
175
|
+
(f": {detail}" if detail else ""))
|
|
176
|
+
elif not step.ok:
|
|
177
|
+
detail = " ".join(step.out.split())[:220]
|
|
178
|
+
attempts.append(f"{step.name} failed" + (f": {detail}" if detail else ""))
|
|
179
|
+
|
|
180
|
+
if reads:
|
|
181
|
+
lines.append("Files already read:\n" + "\n".join(f"- {p}" for p in dict.fromkeys(reads)))
|
|
182
|
+
if changes:
|
|
183
|
+
lines.append("Files already changed:\n" + "\n".join(f"- {p}" for p in dict.fromkeys(changes)))
|
|
184
|
+
if attempts:
|
|
185
|
+
lines.append("What was tried:\n" + "\n".join(f"- {item}" for item in dict.fromkeys(attempts)))
|
|
186
|
+
|
|
187
|
+
skill_results = {m.get("tool_call_id"): m.get("content", "") for m in parent.messages
|
|
188
|
+
if m.get("role") == "tool"}
|
|
189
|
+
skills = []
|
|
190
|
+
for message in parent.messages:
|
|
191
|
+
if message.get("role") != "assistant":
|
|
192
|
+
continue
|
|
193
|
+
for call in message.get("tool_calls") or []:
|
|
194
|
+
fn = call.get("function") or {}
|
|
195
|
+
if fn.get("name") == "use_skill" and call.get("id") in skill_results:
|
|
196
|
+
skills.append(skill_results[call["id"]])
|
|
197
|
+
if skills:
|
|
198
|
+
lines.append("Loaded skill instructions:\n" + "\n\n".join(dict.fromkeys(skills)))
|
|
199
|
+
|
|
200
|
+
lines.append(
|
|
201
|
+
f"Assigned task: {task}\n\nDo only this task. Avoid files changed above unless necessary. "
|
|
202
|
+
"Return a short report of actions, changed files, verification, and anything unresolved.")
|
|
203
|
+
return "\n\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def coordinator_packet(parent, reason: str) -> str:
|
|
207
|
+
return "[Coordinator hand-off packet]\n" + _handoff_packet(
|
|
208
|
+
parent, "Continue the original user goal without repeating completed work.", reason)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_tasks(parent, tasks: list, reason: str = "", isolate: bool | None = None) -> str:
|
|
212
|
+
"""Run tasks concurrently. Tasks are strings ('@worker task' allowed) or
|
|
213
|
+
{task, worker, why} dicts. Returns a combined report (also used as a tool result)."""
|
|
214
|
+
from .agent import Agent # local import: agent imports this module
|
|
215
|
+
from . import roster
|
|
216
|
+
|
|
217
|
+
ui: UI = parent.ui
|
|
218
|
+
tasks = parse_tasks(tasks)
|
|
219
|
+
if any(t["worker"] for t in tasks):
|
|
220
|
+
workers = roster.build(parent.router)
|
|
221
|
+
for t in tasks:
|
|
222
|
+
selected = roster.resolve(workers, t["worker"]) if t["worker"] else None
|
|
223
|
+
t["w"] = selected if selected and roster.usable(selected, parent.router) else None
|
|
224
|
+
limit = parent.settings.max_agents
|
|
225
|
+
was_limited = len(tasks) > limit
|
|
226
|
+
if was_limited:
|
|
227
|
+
tasks = tasks[:limit]
|
|
228
|
+
cancel = threading.Event()
|
|
229
|
+
from . import worktrees
|
|
230
|
+
repo = worktrees.repo_root(parent.tools.root) if worktrees.wanted(isolate) and len(tasks) > 1 else None
|
|
231
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
232
|
+
|
|
233
|
+
ui.stop_spinner()
|
|
234
|
+
if not getattr(parent, "_handoff_status_shown", False):
|
|
235
|
+
targets = list(dict.fromkeys(t["w"].id for t in tasks if t.get("w")))
|
|
236
|
+
target = ", ".join(targets[:3]) if targets else "automatic worker routing"
|
|
237
|
+
if len(targets) > 3:
|
|
238
|
+
target += f" +{len(targets) - 3}"
|
|
239
|
+
status = f"◆ Handed to {target} · {reason or 'independent tasks requested'}"
|
|
240
|
+
if was_limited:
|
|
241
|
+
status += f" · capped at {limit} tasks"
|
|
242
|
+
ui.info(status)
|
|
243
|
+
parent._handoff_status_shown = True
|
|
244
|
+
|
|
245
|
+
def work(i: int, t: dict) -> dict:
|
|
246
|
+
task = t["task"]
|
|
247
|
+
sub_ui = SubUI(ui, i)
|
|
248
|
+
sub = Agent(parent.settings, sub_ui, router=parent.router, ephemeral=True)
|
|
249
|
+
sub.tools.auto_approve = parent.tools.auto_approve
|
|
250
|
+
sub.tools.always = parent.tools.always # "always" answers are shared
|
|
251
|
+
sub.offset = i - 1 # start at a different provider
|
|
252
|
+
sub.prefer = t["w"].prefer if t.get("w") else None # assigned model goes first
|
|
253
|
+
sub.cancel = cancel
|
|
254
|
+
tree = worktrees.create(repo, i, stamp) if repo else None
|
|
255
|
+
packet = _handoff_packet(parent, task, reason or t.get("why", ""))
|
|
256
|
+
if tree:
|
|
257
|
+
sub.tools.root = tree[0]
|
|
258
|
+
sub.messages[0] = dict(sub.messages[0], content=str(sub.messages[0]["content"]).replace(
|
|
259
|
+
str(parent.tools.root), str(tree[0])))
|
|
260
|
+
packet += (f"\n\nYou work in your own git worktree: {tree[0]} (branch {tree[1]}). Use relative "
|
|
261
|
+
"paths or paths inside it and pass cwd for shell commands; other agents work in their "
|
|
262
|
+
"own copies. Don't commit: Forge commits your changes when you finish.")
|
|
263
|
+
sub.messages = [sub.messages[0], {"role": "user", "content": packet}]
|
|
264
|
+
started = time.time()
|
|
265
|
+
try:
|
|
266
|
+
sub._loop()
|
|
267
|
+
except Exception as e: # one agent failing must not take down the others
|
|
268
|
+
sub_ui.final = sub_ui.final or f"Agent crashed: {e}"
|
|
269
|
+
if tree:
|
|
270
|
+
sub_ui.final = (sub_ui.final or "") + "\n\nWorktree: " + worktrees.finish(repo, tree[0], tree[1], task)
|
|
271
|
+
who = sub.last_answer or "no provider answered"
|
|
272
|
+
assigned = t["w"].id if t.get("w") else ""
|
|
273
|
+
if assigned and sub.last_answer and sub.last_answer.replace(" · ", ":") != assigned:
|
|
274
|
+
who += f" (assigned {assigned}, which failed, so failover answered)"
|
|
275
|
+
return {"n": i, "task": task, "report": sub_ui.final or "(no report — it may have failed or been stopped)",
|
|
276
|
+
"who": who, "assigned": assigned, "secs": time.time() - started,
|
|
277
|
+
"evidence": copy.deepcopy(sub.tools.ledger.steps)}
|
|
278
|
+
|
|
279
|
+
results: list[dict] = []
|
|
280
|
+
city = getattr(ui, "city", None)
|
|
281
|
+
if city is not None: # helper cubs join the delivery run, one per agent
|
|
282
|
+
city.set_helpers(len(tasks))
|
|
283
|
+
ui.act("delegating")
|
|
284
|
+
pool = ThreadPoolExecutor(max_workers=len(tasks), thread_name_prefix="forge-agent")
|
|
285
|
+
futures = [pool.submit(work, i, t) for i, t in enumerate(tasks, 1)]
|
|
286
|
+
try:
|
|
287
|
+
for f in futures:
|
|
288
|
+
while not f.done(): # short sleeps keep ctrl+c responsive on Windows
|
|
289
|
+
time.sleep(0.2)
|
|
290
|
+
results.append(f.result())
|
|
291
|
+
except KeyboardInterrupt:
|
|
292
|
+
cancel.set()
|
|
293
|
+
ui.warn("stopping agents after their current step…")
|
|
294
|
+
results = [f.result() for f in futures]
|
|
295
|
+
finally:
|
|
296
|
+
pool.shutdown(wait=True)
|
|
297
|
+
if city is not None:
|
|
298
|
+
city.set_helpers(0)
|
|
299
|
+
ui.act("thinking") # back to the main agent, which reads the reports
|
|
300
|
+
|
|
301
|
+
for result in results:
|
|
302
|
+
parent.tools.ledger.steps.extend(result["evidence"])
|
|
303
|
+
|
|
304
|
+
for r in results:
|
|
305
|
+
ui.panel(Markdown(r["report"].strip(), code_theme="monokai"),
|
|
306
|
+
title=f"agent {r['n']} · {r['task'][:60]}", subtitle=f"{r['who'][:90]} · {r['secs']:.0f}s")
|
|
307
|
+
return "\n\n".join(f"### Agent {r['n']}: {r['task']}\n(answered by {r['who']})\n\n{r['report']}"
|
|
308
|
+
for r in results)
|
forgecli/prices.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""What a session's tokens would cost. Most of Forge's providers are free tiers (cost $0); for paid
|
|
2
|
+
ones this is an estimate from public list prices per million tokens, which change - put your own in
|
|
3
|
+
~/.forge/prices.json as {"provider:model" or "model": [input_per_million, output_per_million]}.
|
|
4
|
+
Cached-input discounts are not counted, so real bills can be lower."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
# (input $, output $) per 1M tokens. Matched against "provider:model", then the model name, then a
|
|
12
|
+
# substring of the model name. Unknown paid models show "price unknown" rather than a guess.
|
|
13
|
+
LIST = {
|
|
14
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
15
|
+
"gpt-4o": (2.50, 10.00),
|
|
16
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
17
|
+
"gpt-4.1": (2.00, 8.00),
|
|
18
|
+
"o4-mini": (1.10, 4.40),
|
|
19
|
+
"claude-haiku": (1.00, 5.00),
|
|
20
|
+
"claude-sonnet": (3.00, 15.00),
|
|
21
|
+
"claude-opus": (15.00, 75.00),
|
|
22
|
+
"gemini-2.5-flash": (0.30, 2.50),
|
|
23
|
+
"gemini-2.5-pro": (1.25, 10.00),
|
|
24
|
+
"deepseek-chat": (0.27, 1.10),
|
|
25
|
+
"deepseek-reasoner": (0.55, 2.19),
|
|
26
|
+
}
|
|
27
|
+
# Providers whose keys in Forge are free tiers: no money changes hands.
|
|
28
|
+
FREE_PROVIDERS = {"groq", "cerebras", "nvidia", "llm7", "ovh", "kilo", "github", "cloudflare", "sambanova",
|
|
29
|
+
"mistral", "cohere", "chutes", "pollinations"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _user_prices() -> dict:
|
|
33
|
+
p = pathlib.Path(os.environ.get("FORGE_HOME", pathlib.Path.home() / ".forge")) / "prices.json"
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
36
|
+
return {str(k).lower(): (float(v[0]), float(v[1])) for k, v in data.items()}
|
|
37
|
+
except (OSError, ValueError, TypeError, IndexError, AttributeError):
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def price(provider: str, model: str) -> tuple[float, float] | None:
|
|
42
|
+
"""(input, output) dollars per million tokens; (0, 0) for free; None when unknown."""
|
|
43
|
+
prov, mod = (provider or "").lower(), (model or "").lower()
|
|
44
|
+
user = _user_prices()
|
|
45
|
+
for key in (f"{prov}:{mod}", mod):
|
|
46
|
+
if key in user:
|
|
47
|
+
return user[key]
|
|
48
|
+
if prov in FREE_PROVIDERS or mod.endswith(":free") or "/free" in mod or mod == "free":
|
|
49
|
+
return (0.0, 0.0)
|
|
50
|
+
if prov == "openrouter" and mod.endswith(":free"):
|
|
51
|
+
return (0.0, 0.0)
|
|
52
|
+
for name in sorted(LIST, key=len, reverse=True):
|
|
53
|
+
if name in mod:
|
|
54
|
+
return LIST[name]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def summary(by_model: dict) -> tuple[list[str], float, bool]:
|
|
59
|
+
"""Lines for /cost, the total in dollars, and whether any model's price was unknown."""
|
|
60
|
+
lines, total, unknown = [], 0.0, False
|
|
61
|
+
for key, (tin, tout) in sorted(by_model.items(), key=lambda kv: -(kv[1][0] + kv[1][1])):
|
|
62
|
+
prov, _, mod = key.partition(":")
|
|
63
|
+
p = price(prov, mod)
|
|
64
|
+
if p is None:
|
|
65
|
+
unknown = True
|
|
66
|
+
cost = "price unknown"
|
|
67
|
+
else:
|
|
68
|
+
dollars = (tin * p[0] + tout * p[1]) / 1_000_000
|
|
69
|
+
total += dollars
|
|
70
|
+
cost = "free" if p == (0.0, 0.0) else f"${dollars:,.4f}"
|
|
71
|
+
lines.append(f"{prov} · {mod}: {tin:,} in / {tout:,} out — {cost}")
|
|
72
|
+
return lines, total, unknown
|
forgecli/procs.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Running shell commands without ever hanging the chat.
|
|
2
|
+
|
|
3
|
+
Output goes to a log file instead of pipes. (On Windows, a pipe stays open while any
|
|
4
|
+
grandchild holds it, so a server started through powershell made subprocess.run wait
|
|
5
|
+
forever even after its timeout.) A command that turns out to be a server (it prints a
|
|
6
|
+
local URL and keeps running) is moved to the background automatically, so the agent can
|
|
7
|
+
carry on and open it in the browser. Background processes are stopped when Forge exits."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
LOG_DIR = Path(tempfile.gettempdir()) / "forge-cli-logs"
|
|
21
|
+
URL_RE = re.compile(r"https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w.-]+):\d{2,5}[^\s'\"<>]*", re.I)
|
|
22
|
+
SERVER_HINT = re.compile(r"running on|listening|serving|server (?:is )?(?:running|started)|"
|
|
23
|
+
r"press ctrl\+c|ready in|local:|started server|development server", re.I)
|
|
24
|
+
WIN = os.name == "nt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Proc:
|
|
29
|
+
id: int
|
|
30
|
+
command: str
|
|
31
|
+
cwd: str
|
|
32
|
+
popen: subprocess.Popen
|
|
33
|
+
log: Path
|
|
34
|
+
started: float = field(default_factory=time.time)
|
|
35
|
+
|
|
36
|
+
def alive(self) -> bool:
|
|
37
|
+
return self.popen.poll() is None
|
|
38
|
+
|
|
39
|
+
def output(self, tail: int = 8000) -> str:
|
|
40
|
+
try:
|
|
41
|
+
data = self.log.read_text(encoding="utf-8", errors="replace")
|
|
42
|
+
except OSError:
|
|
43
|
+
return ""
|
|
44
|
+
return data[-tail:]
|
|
45
|
+
|
|
46
|
+
def urls(self) -> list[str]:
|
|
47
|
+
seen = []
|
|
48
|
+
for u in URL_RE.findall(self.output(200_000)):
|
|
49
|
+
u = u.rstrip(".,);]").replace("0.0.0.0", "127.0.0.1")
|
|
50
|
+
if u not in seen:
|
|
51
|
+
seen.append(u)
|
|
52
|
+
return seen
|
|
53
|
+
|
|
54
|
+
def state(self) -> str:
|
|
55
|
+
return "running" if self.alive() else f"exited ({self.popen.returncode})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_PROCS: dict[int, Proc] = {}
|
|
59
|
+
_LOCK = threading.Lock()
|
|
60
|
+
_NEXT = [1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def argv_for(shell: str, command: str) -> list[str]:
|
|
64
|
+
if shell == "powershell":
|
|
65
|
+
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", command]
|
|
66
|
+
if shell == "pwsh":
|
|
67
|
+
return ["pwsh", "-NoProfile", "-NonInteractive", "-Command", command]
|
|
68
|
+
if shell == "cmd":
|
|
69
|
+
return ["cmd", "/c", command]
|
|
70
|
+
return [shell, "-lc", command]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def start(shell: str, command: str, cwd: str, argv: list[str] | None = None) -> Proc:
|
|
74
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
with _LOCK:
|
|
76
|
+
pid_n = _NEXT[0]
|
|
77
|
+
_NEXT[0] += 1
|
|
78
|
+
log = LOG_DIR / f"{os.getpid()}-{pid_n}.log"
|
|
79
|
+
fh = open(log, "wb")
|
|
80
|
+
env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
|
|
81
|
+
kw = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WIN else {"start_new_session": True}
|
|
82
|
+
try:
|
|
83
|
+
popen = subprocess.Popen(argv or argv_for(shell, command), cwd=cwd, stdout=fh, stderr=subprocess.STDOUT,
|
|
84
|
+
stdin=subprocess.DEVNULL, env=env, **kw)
|
|
85
|
+
finally:
|
|
86
|
+
fh.close() # the child has its own handle
|
|
87
|
+
p = Proc(pid_n, command, cwd, popen, log)
|
|
88
|
+
with _LOCK:
|
|
89
|
+
_PROCS[pid_n] = p
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def kill(p: Proc) -> None:
|
|
94
|
+
"""Stop the process and everything it started."""
|
|
95
|
+
if not p.alive():
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
if WIN:
|
|
99
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(p.popen.pid)],
|
|
100
|
+
capture_output=True, timeout=15)
|
|
101
|
+
else:
|
|
102
|
+
import signal
|
|
103
|
+
os.killpg(p.popen.pid, signal.SIGTERM)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
try:
|
|
107
|
+
p.popen.wait(timeout=5)
|
|
108
|
+
except Exception:
|
|
109
|
+
try:
|
|
110
|
+
p.popen.kill()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def forget(p: Proc) -> None:
|
|
116
|
+
with _LOCK:
|
|
117
|
+
_PROCS.pop(p.id, None)
|
|
118
|
+
try:
|
|
119
|
+
p.log.unlink()
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get(n) -> Proc | None:
|
|
125
|
+
try:
|
|
126
|
+
return _PROCS.get(int(n))
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def all_procs() -> list[Proc]:
|
|
132
|
+
return list(_PROCS.values())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def looks_like_server(p: Proc) -> bool:
|
|
136
|
+
out = p.output(20_000)
|
|
137
|
+
return bool(URL_RE.search(out)) or bool(SERVER_HINT.search(out))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@atexit.register
|
|
141
|
+
def _cleanup() -> None:
|
|
142
|
+
for p in all_procs():
|
|
143
|
+
kill(p)
|
|
144
|
+
forget(p)
|