pi-factory-gate 1.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/AGENTS.md ADDED
@@ -0,0 +1,78 @@
1
+ # Factory Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use factory-gate tools to interact with the wrok.in AI Factory orchestrator.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ factory_status() # Check factory health
9
+ factory_list_agents() # See available agents
10
+ factory_create_job(mode="single", agent="code-reviewer", task="Review the diff")
11
+ factory_stream_job(id="<job-id>") # Wait for completion
12
+ ```
13
+
14
+ ## Job Modes
15
+
16
+ - **single** — one agent, one task: `factory_create_job(mode="single", agent="analyzer", task="Analyze the codebase")`
17
+ - **chain** — sequential agents: `factory_create_job(mode="chain", steps='[{"agent":"planner","task":"Plan"},{"agent":"builder","task":"Build"}]')`
18
+ - **parallel** — concurrent agents: `factory_create_job(mode="parallel", tasks='[{"agent":"linter","task":"Lint"},{"agent":"tester","task":"Test"}]')`
19
+
20
+ ## Environments
21
+
22
+ ```bash
23
+ factory_list_environments() # List registered environments
24
+ factory_create_environment(name="app", repo="https://github.com/user/app.git")
25
+ factory_create_job(mode="single", agent="builder", task="Build", environment="app")
26
+ ```
27
+
28
+ Skip environments with direct repo dispatch:
29
+ ```bash
30
+ factory_create_job(mode="single", agent="analyzer", task="Analyze", repo="https://github.com/user/repo.git", branch="main")
31
+ ```
32
+
33
+ ## Workflows & Monitoring
34
+
35
+ ```bash
36
+ factory_list_workflows() # List workflow templates
37
+ factory_run_workflow(template="ci-review") # Execute a workflow
38
+ factory_list_workers() # Worker health
39
+ factory_get_events() # Recent system events
40
+ ```
41
+
42
+ ## Job Lifecycle
43
+
44
+ ```
45
+ factory_create_job(...)
46
+
47
+
48
+ factory_get_job(id="...") ← check status anytime
49
+
50
+
51
+ factory_stream_job(id="...") ← wait for completion
52
+
53
+
54
+ [result] ← final state: completed | failed | partial
55
+ ```
56
+
57
+ ## Troubleshooting
58
+
59
+ | Problem | Solution |
60
+ |---------|----------|
61
+ | `factory_status()` fails | Check that the orchestrator is running. Verify `.factoryrc.yml` has the correct `orchestratorUrl`. |
62
+ | Job stays "pending" | All workers are busy or offline. Check `factory_list_workers()`. |
63
+ | "Environment not found" | Run `factory_list_environments()` to see what's available, or create one with `factory_create_environment()`. |
64
+ | 409 Conflict on job create | An exclusive agent is already working on the same branch. Wait for it to complete or use `force: true`. |
65
+ | 503 "Job creation disabled" | Jobs are toggled off in the factory dashboard. Toggle "Jobs On" to re-enable. |
66
+ | "Agent not found" | Run `factory_list_agents()` to see available agents. Check the environment context. |
67
+
68
+ ## Config Reference
69
+
70
+ `.factoryrc.yml`:
71
+
72
+ | Key | Default | Description |
73
+ |-----|---------|-------------|
74
+ | `orchestratorUrl` | `http://localhost:3001` | Factory orchestrator URL |
75
+ | `defaultEnvironment` | (none) | Default environment for jobs |
76
+ | `defaultLimit` | `20` | Items per list operation |
77
+ | `requestTimeout` | `15000` | API timeout in ms |
78
+ | `maxLogLines` | `500` | Max output lines for streaming |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # pi-factory-gate
2
+
3
+ AI Factory orchestration gateway for pi agents — dispatch jobs, manage environments, list agents/workers/workflows, and stream results from a [wrok.in](https://github.com/nandal/wrok.in) orchestrator.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-factory-gate
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Add a `.factoryrc.yml` to your repo root:
14
+
15
+ ```yaml
16
+ orchestratorUrl: http://localhost:3001
17
+ defaultEnvironment: default
18
+ defaultLimit: 20
19
+ requestTimeout: 15000
20
+ maxLogLines: 500
21
+ ```
22
+
23
+ | Key | Default | Description |
24
+ |-----|---------|-------------|
25
+ | `orchestratorUrl` | `http://localhost:3001` | Base URL of the wrok.in orchestrator |
26
+ | `defaultEnvironment` | (none) | Default environment for job dispatch |
27
+ | `defaultLimit` | `20` | Default page size for list operations |
28
+ | `requestTimeout` | `15000` | API call timeout in ms |
29
+ | `maxLogLines` | `500` | Max lines for streamed job output |
30
+
31
+ ## Tools
32
+
33
+ ### Agents
34
+
35
+ | Tool | Description |
36
+ |------|-------------|
37
+ | `factory_list_agents` | List all agents, optionally filtered by environment |
38
+ | `factory_get_agent` | Get agent detail including inheritance and resolved config |
39
+
40
+ ### Jobs
41
+
42
+ | Tool | Description |
43
+ |------|-------------|
44
+ | `factory_create_job` | Dispatch a job (single, chain, or parallel mode) |
45
+ | `factory_get_job` | Get job status and results |
46
+ | `factory_list_jobs` | List all jobs in the factory |
47
+ | `factory_stream_job` | Poll for completion, returns final state |
48
+
49
+ ### Environments
50
+
51
+ | Tool | Description |
52
+ |------|-------------|
53
+ | `factory_list_environments` | List all registered environments |
54
+ | `factory_get_environment` | Get environment detail |
55
+ | `factory_create_environment` | Register a new environment (clone a repo) |
56
+
57
+ ### Workers
58
+
59
+ | Tool | Description |
60
+ |------|-------------|
61
+ | `factory_list_workers` | List all workers with health/status |
62
+
63
+ ### Workflows
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `factory_list_workflows` | List workflow templates (DB + disk discovery) |
68
+ | `factory_run_workflow` | Execute a workflow template |
69
+
70
+ ### Events & Status
71
+
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `factory_get_events` | Get recent system events |
75
+ | `factory_status` | Health check — is the orchestrator reachable? |
76
+
77
+ ## About wrok.in
78
+
79
+ This gate works with the [wrok.in](https://github.com/nandal/wrok.in) AI Factory — a personal AI workflow orchestrator that manages pi workers across Docker containers with multi-repo environments, agent inheritance, DB-backed agents, and workflow scheduling.
80
+
81
+ ## License
82
+
83
+ MIT
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "pi-factory-gate",
3
+ "version": "1.0.0",
4
+ "description": "AI Factory gateway for pi agents — dispatch jobs, manage environments, list agents/workers/workflows, stream results from a wrok.in orchestrator.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "ai-factory",
9
+ "orchestrator",
10
+ "agent-management",
11
+ "job-dispatch",
12
+ "workflow"
13
+ ],
14
+ "author": "nandal <nandal@users.noreply.github.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/nandal/pi-ext",
18
+ "directory": "factory-gate"
19
+ },
20
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/factory-gate",
21
+ "bugs": {
22
+ "url": "https://github.com/nandal/pi-ext/issues"
23
+ },
24
+ "license": "MIT",
25
+ "main": "./src/index.ts",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "AGENTS.md",
33
+ "LICENSE"
34
+ ],
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^2.1.9"
50
+ }
51
+ }
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { FactoryConfig } from "./types";
4
+ import { DEFAULT_CONFIG } from "./types";
5
+
6
+ export function loadConfig(cwd: string): FactoryConfig {
7
+ const configPath = path.join(cwd, ".factoryrc.yml");
8
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
9
+ try {
10
+ const content = fs.readFileSync(configPath, "utf-8");
11
+ const result: Record<string, unknown> = {};
12
+ for (const line of content.split("\n")) {
13
+ const m = line.match(/^\s*(\w[\w.]*):\s*(.+)$/);
14
+ if (m) {
15
+ let val = m[2].trim();
16
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
17
+ val = val.slice(1, -1);
18
+ }
19
+ result[m[1]] = val;
20
+ }
21
+ }
22
+ return {
23
+ orchestratorUrl: (result["orchestratorUrl"] as string) || DEFAULT_CONFIG.orchestratorUrl,
24
+ defaultEnvironment: (result["defaultEnvironment"] as string) || DEFAULT_CONFIG.defaultEnvironment,
25
+ defaultLimit: parseInt(result["defaultLimit"] as string) || DEFAULT_CONFIG.defaultLimit,
26
+ requestTimeout: parseInt(result["requestTimeout"] as string) || DEFAULT_CONFIG.requestTimeout,
27
+ maxLogLines: parseInt(result["maxLogLines"] as string) || DEFAULT_CONFIG.maxLogLines,
28
+ };
29
+ } catch {
30
+ return { ...DEFAULT_CONFIG };
31
+ }
32
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { FactoryConfig } from "./types";
2
+
3
+ /**
4
+ * Call the wrok.in orchestrator API.
5
+ * Handles JSON parsing, error extraction, and timeouts.
6
+ */
7
+ export async function factoryApi<T = unknown>(
8
+ config: FactoryConfig,
9
+ endpoint: string,
10
+ method: "GET" | "POST" | "DELETE" = "GET",
11
+ body?: unknown,
12
+ ): Promise<{ ok: boolean; data?: T; error?: string; status: number }> {
13
+ const url = `${config.orchestratorUrl.replace(/\/$/, "")}${endpoint}`;
14
+ const controller = new AbortController();
15
+ const timeout = setTimeout(() => controller.abort(), config.requestTimeout);
16
+
17
+ try {
18
+ const opts: RequestInit = {
19
+ method,
20
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
21
+ signal: controller.signal,
22
+ };
23
+ if (body !== undefined && method !== "GET") {
24
+ opts.body = JSON.stringify(body);
25
+ }
26
+
27
+ const res = await fetch(url, opts);
28
+ clearTimeout(timeout);
29
+
30
+ const contentType = res.headers.get("content-type") || "";
31
+ const isJson = contentType.includes("application/json");
32
+
33
+ if (!res.ok) {
34
+ const errBody = isJson ? await res.json().catch(() => null) : await res.text().catch(() => null);
35
+ const msg = errBody && typeof errBody === "object" && "error" in errBody
36
+ ? String((errBody as Record<string, unknown>).error)
37
+ : String(errBody || res.statusText);
38
+ return { ok: false, error: msg, status: res.status };
39
+ }
40
+
41
+ if (isJson) {
42
+ const data = await res.json() as T;
43
+ return { ok: true, data, status: res.status };
44
+ }
45
+
46
+ const text = await res.text();
47
+ return { ok: true, data: text as unknown as T, status: res.status };
48
+ } catch (err: unknown) {
49
+ clearTimeout(timeout);
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ if (msg.includes("abort") || msg.includes("timeout")) {
52
+ return { ok: false, error: `Request timed out after ${config.requestTimeout}ms`, status: 0 };
53
+ }
54
+ return { ok: false, error: msg, status: 0 };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Format a Unix timestamp into a human-readable string.
60
+ */
61
+ export function formatTimestamp(ts: number): string {
62
+ return new Date(ts).toISOString().slice(0, 19).replace("T", " ");
63
+ }
64
+
65
+ /**
66
+ * Format duration in ms to a human-readable string.
67
+ */
68
+ export function formatDuration(ms: number): string {
69
+ if (ms < 1000) return `${ms}ms`;
70
+ const s = Math.floor(ms / 1000);
71
+ if (s < 60) return `${s}s`;
72
+ const m = Math.floor(s / 60);
73
+ if (m < 60) return `${m}m ${s % 60}s`;
74
+ const h = Math.floor(m / 60);
75
+ return `${h}h ${m % 60}m`;
76
+ }
77
+
78
+ /**
79
+ * Truncate a long string, keeping head and tail for readability.
80
+ */
81
+ export function truncateOutput(text: string, maxLines: number): { text: string; truncated: boolean; totalLines: number } {
82
+ const lines = text.split("\n");
83
+ const totalLines = lines.length;
84
+ if (totalLines <= maxLines) return { text, truncated: false, totalLines };
85
+
86
+ const head = Math.ceil(maxLines * 0.6);
87
+ const tail = maxLines - head;
88
+ const truncated =
89
+ lines.slice(0, head).join("\n") +
90
+ `\n\n... [${totalLines - maxLines} lines truncated] ...\n\n` +
91
+ lines.slice(-tail).join("\n");
92
+
93
+ return { text: truncated, truncated: true, totalLines };
94
+ }
95
+
96
+ /**
97
+ * Status icon helper for job/worker display.
98
+ */
99
+ export function statusIcon(status: string): string {
100
+ const s = status.toLowerCase();
101
+ if (s === "running" || s === "in_progress") return "🔄";
102
+ if (s === "pending" || s === "queued") return "⏳";
103
+ if (s === "completed" || s === "success") return "✅";
104
+ if (s === "failed" || s === "failure") return "❌";
105
+ if (s === "partial") return "⚠️";
106
+ if (s === "busy") return "🔵";
107
+ if (s === "idle") return "🟢";
108
+ if (s === "offline") return "🔴";
109
+ if (s === "ready") return "✅";
110
+ if (s === "cloning") return "⏳";
111
+ if (s === "empty") return "⚪";
112
+ if (s === "error") return "❌";
113
+ return "⚪";
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * pi-factory-gate — AI Factory Orchestration Gate
3
+ *
4
+ * Tools: factory_list_agents, factory_get_agent, factory_create_job,
5
+ * factory_get_job, factory_list_jobs, factory_stream_job,
6
+ * factory_list_environments, factory_get_environment, factory_create_environment,
7
+ * factory_list_workers, factory_list_workflows, factory_run_workflow,
8
+ * factory_get_events, factory_status
9
+ * Config: .factoryrc.yml
10
+ */
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { listAgentsTool, getAgentTool } from "./tools/agents";
13
+ import { createJobTool, getJobTool, listJobsTool, streamJobTool } from "./tools/jobs";
14
+ import { listEnvironmentsTool, getEnvironmentTool, createEnvironmentTool } from "./tools/environments";
15
+ import { listWorkersTool } from "./tools/workers";
16
+ import { listWorkflowsTool, runWorkflowTool } from "./tools/workflows";
17
+ import { getEventsTool, statusTool } from "./tools/events";
18
+
19
+ export default function (pi: ExtensionAPI) {
20
+ // Agents
21
+ pi.registerTool(listAgentsTool);
22
+ pi.registerTool(getAgentTool);
23
+
24
+ // Jobs
25
+ pi.registerTool(createJobTool);
26
+ pi.registerTool(getJobTool);
27
+ pi.registerTool(listJobsTool);
28
+ pi.registerTool(streamJobTool);
29
+
30
+ // Environments
31
+ pi.registerTool(listEnvironmentsTool);
32
+ pi.registerTool(getEnvironmentTool);
33
+ pi.registerTool(createEnvironmentTool);
34
+
35
+ // Workers
36
+ pi.registerTool(listWorkersTool);
37
+
38
+ // Workflows
39
+ pi.registerTool(listWorkflowsTool);
40
+ pi.registerTool(runWorkflowTool);
41
+
42
+ // Events & Status
43
+ pi.registerTool(getEventsTool);
44
+ pi.registerTool(statusTool);
45
+ }
@@ -0,0 +1,119 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { factoryApi } from "../helpers";
5
+ import type { AgentInfo, AgentDetail } from "../types";
6
+
7
+ // ─── List Agents ───────────────────────────────────────────────────────────────
8
+
9
+ export const listAgentsTool = {
10
+ name: "factory_list_agents" as const,
11
+ label: "List Factory Agents",
12
+ description:
13
+ "List all available AI agents in the wrok.in factory. Optionally filter by environment.",
14
+ parameters: Type.Object({
15
+ environment: Type.Optional(
16
+ Type.String({ description: "Filter agents by environment name" }),
17
+ ),
18
+ }),
19
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
20
+ const config = loadConfig(ctx.cwd);
21
+ const qs = params.environment ? `?environment=${encodeURIComponent(params.environment)}` : "";
22
+ const r = await factoryApi<{ agents: AgentInfo[]; environment: string }>(
23
+ config,
24
+ `/agents${qs}`,
25
+ );
26
+
27
+ if (!r.ok || !r.data) {
28
+ return {
29
+ content: [{ type: "text", text: `❌ Failed to list agents: ${r.error || "unknown"}` }],
30
+ isError: true,
31
+ details: {},
32
+ };
33
+ }
34
+
35
+ const { agents } = r.data;
36
+ if (agents.length === 0) {
37
+ return {
38
+ content: [{ type: "text", text: "No agents registered." }],
39
+ details: { count: 0 },
40
+ };
41
+ }
42
+
43
+ const lines = [`🤖 Factory Agents (${agents.length})` + (params.environment ? ` in "${params.environment}"` : ""), ""];
44
+ for (const a of agents) {
45
+ const badge = a.enabled === false ? "🔴" : "🟢";
46
+ const tools = a.tools?.length ? ` [${a.tools.slice(0, 6).join(", ")}${a.tools.length > 6 ? ", ..." : ""}]` : "";
47
+ lines.push(` ${badge} ${a.name} source=${a.source}`);
48
+ lines.push(` ${a.description || "(no description)"}${tools}`);
49
+ if (a.model && a.model !== "auto") {
50
+ lines.push(` model: ${a.model}`);
51
+ }
52
+ }
53
+
54
+ return {
55
+ content: [{ type: "text", text: lines.join("\n") }],
56
+ details: { count: agents.length, environment: params.environment || "default" },
57
+ };
58
+ },
59
+ };
60
+
61
+ // ─── Get Agent ─────────────────────────────────────────────────────────────────
62
+
63
+ export const getAgentTool = {
64
+ name: "factory_get_agent" as const,
65
+ label: "Get Agent Detail",
66
+ description:
67
+ "Get detailed information about a specific factory agent, including its resolved configuration (inheritance, overrides).",
68
+ parameters: Type.Object({
69
+ name: Type.String({ description: "Agent name (e.g., 'code-reviewer', 'build-runner')" }),
70
+ environment: Type.Optional(
71
+ Type.String({ description: "Environment context for agent resolution" }),
72
+ ),
73
+ }),
74
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
75
+ const config = loadConfig(ctx.cwd);
76
+ const qs = params.environment ? `?environment=${encodeURIComponent(params.environment)}` : "";
77
+ const r = await factoryApi<AgentDetail>(
78
+ config,
79
+ `/agents/${encodeURIComponent(params.name)}${qs}`,
80
+ );
81
+
82
+ if (!r.ok) {
83
+ return {
84
+ content: [{ type: "text", text: `❌ Agent "${params.name}" not found: ${r.error || "unknown"}` }],
85
+ isError: true,
86
+ details: {},
87
+ };
88
+ }
89
+
90
+ const a = r.data!;
91
+ const lines = [
92
+ `🤖 ${a.name}`,
93
+ ` Description: ${a.description || "(none)"}`,
94
+ ` Source: ${a.source}`,
95
+ ` Resolved: ${a.resolvedFrom || "(direct)"}`,
96
+ ` Override: ${a.isOverride ? "yes" : "no"}`,
97
+ ` Enabled: ${a.enabled !== false ? "yes" : "no"}`,
98
+ ];
99
+
100
+ if (a.extends) lines.push(` Extends: ${a.extends}`);
101
+ if (a.model && a.model !== "auto") lines.push(` Model: ${a.model}`);
102
+ if (a.tools?.length) {
103
+ lines.push(` Tools (${a.tools.length}):`);
104
+ for (const t of a.tools) lines.push(` - ${t}`);
105
+ }
106
+ if (a.systemPrompt) {
107
+ const preview = a.systemPrompt.length > 300
108
+ ? a.systemPrompt.slice(0, 300) + "..."
109
+ : a.systemPrompt;
110
+ lines.push(` System Prompt:`);
111
+ lines.push(` ${preview}`);
112
+ }
113
+
114
+ return {
115
+ content: [{ type: "text", text: lines.join("\n") }],
116
+ details: { name: a.name, source: a.source, resolvedFrom: a.resolvedFrom },
117
+ };
118
+ },
119
+ };