@omac/pi 0.0.0-stage → 0.0.1-beta.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/LICENSE +21 -0
- package/package.json +41 -4
- package/src/extension.ts +166 -0
- package/src/pi.ts +61 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iamdin
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omac/pi",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-beta.0",
|
|
4
|
+
"description": "omac's Pi integration: the Worker's turn-end report, the Coordinator's inbox delivery, and both roles' guard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/iamdin/orchestrate-my-agent-cli.git",
|
|
10
|
+
"directory": "packages/pi-omac"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/extension.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "cd ../.. && bun --bun vitest run packages/pi-omac"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@effect/platform-node-shared": "4.0.2",
|
|
33
|
+
"effect-herdr-sdk": "0.0.1-beta.0",
|
|
34
|
+
"effect": "4.0.2",
|
|
35
|
+
"@omac/cli": "0.0.1-beta.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "1.1.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// omac's Pi extension. OMAC_WORKER_ID makes it a Worker (turn-end result file), OMAC_COORDINATOR=1 a Coordinator (inbox
|
|
2
|
+
// rows delivered in process instead of typed into the pane); otherwise it is inert. Either way tool calls go through
|
|
3
|
+
// that role's rules in omac's guard.ts, in process. Both roles run omac itself as `omac` commands in Pi's bash tool.
|
|
4
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
5
|
+
import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner"
|
|
6
|
+
import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem"
|
|
7
|
+
import * as NodePath from "@effect/platform-node-shared/NodePath"
|
|
8
|
+
import * as NodeSocket from "@effect/platform-node-shared/NodeSocket"
|
|
9
|
+
import { SocketConnector } from "effect-herdr-sdk"
|
|
10
|
+
import { type ClientServices, connect, withOmac } from "@omac/cli/src/client.ts"
|
|
11
|
+
import * as Guard from "@omac/cli/src/guard.ts"
|
|
12
|
+
import { Layout, OmacCommand, writeAtomic } from "@omac/cli/src/paths.ts"
|
|
13
|
+
import { Cause, Clock, Config, Deferred, Effect, FileSystem, Layer, Option, Path, Ref, Schedule, Schema, Stream } from "effect"
|
|
14
|
+
import { Pi } from "./pi.ts"
|
|
15
|
+
|
|
16
|
+
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
|
|
17
|
+
|
|
18
|
+
/** omac's tool-call rules for this role, as Pi's tool_call result. */
|
|
19
|
+
const guarded = (rules: Guard.Rules) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const pi = yield* Pi
|
|
22
|
+
yield* pi.handle("tool_call", (e, ctx) =>
|
|
23
|
+
rules({ tool: e.toolName, input: e.input, cwd: ctx.cwd }).pipe(Effect.map((reason) => (reason ? { block: true, reason } : undefined))),
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
type AgentMessage = AgentEndEvent["messages"][number]
|
|
28
|
+
type AssistantMessage = Extract<AgentMessage, { readonly role: "assistant" }>
|
|
29
|
+
|
|
30
|
+
/** Text and error of the last assistant message of a run. */
|
|
31
|
+
const lastReply = (messages: ReadonlyArray<AgentMessage>) => {
|
|
32
|
+
const message = messages.findLast((m): m is AssistantMessage => m.role === "assistant")
|
|
33
|
+
const text = (message?.content ?? []).flatMap((c) => (c.type === "text" ? [c.text] : [])).join("")
|
|
34
|
+
return { text, error: message?.stopReason === "error" ? message.errorMessage || "error" : "" }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a role's layer needs; the roles share one type, so picking one is a plain conditional. */
|
|
38
|
+
type Role = Layer.Layer<never, Config.ConfigError, Pi | ClientServices | FileSystem.FileSystem>
|
|
39
|
+
|
|
40
|
+
const worker = (workerId: string): Role =>
|
|
41
|
+
Layer.effectDiscard(
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const pi = yield* Pi
|
|
44
|
+
const layout = yield* Layout
|
|
45
|
+
yield* guarded(Guard.worker)
|
|
46
|
+
// TUI only: RPC / JSON / print modes have no PTY, so they are not the Worker's root session.
|
|
47
|
+
const root = yield* Ref.make(false)
|
|
48
|
+
const reply = yield* Ref.make({ text: "", error: "" })
|
|
49
|
+
yield* pi.handle("session_start", (_e, ctx) => Ref.set(root, ctx.mode === "tui"))
|
|
50
|
+
yield* pi.handle("agent_end", (e) => Ref.set(reply, lastReply(e.messages)))
|
|
51
|
+
yield* pi.handle("agent_settled", (e, ctx) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
if (!(yield* Ref.get(root)) || !ctx.isIdle()) return
|
|
54
|
+
const { text, error } = yield* Ref.get(reply)
|
|
55
|
+
const ts = Math.floor((yield* Clock.currentTimeMillis) / 1000)
|
|
56
|
+
const payload = { text, error: error || (e.aborted ? "aborted" : ""), aborted: e.aborted }
|
|
57
|
+
yield* writeAtomic(layout.resultFile(workerId), encodeJson({ harness: "pi", ts, payload }))
|
|
58
|
+
// omacd also finds the file on its next check; the call only makes that immediate.
|
|
59
|
+
yield* connect(layout.socket).pipe(
|
|
60
|
+
Effect.flatMap((client) => client.ResultReady({ workerId })),
|
|
61
|
+
Effect.scoped,
|
|
62
|
+
Effect.timeout("500 millis"),
|
|
63
|
+
Effect.ignore,
|
|
64
|
+
)
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `omac launch pi` reopens the session it reported: after a TUI turn (Pi has written the session by `agent_end`) whose
|
|
72
|
+
* session id is not reported yet, send it in the background, so neither the turn nor omacd's absence holds Pi up. omacd
|
|
73
|
+
* keeps the newest by `reportedAt`, whatever order sends arrive in; one omacd did not answer warns and is sent again after
|
|
74
|
+
* the next turn. Only from the process `omac launch` exec'd: a Pi nested in the Coordinator inherits OMAC_LAUNCH_ID.
|
|
75
|
+
*/
|
|
76
|
+
const reportSession = Effect.fn("reportSession")(function* (launchId: string) {
|
|
77
|
+
const pi = yield* Pi
|
|
78
|
+
const layout = yield* Layout
|
|
79
|
+
if ((yield* Config.String("OMAC_LAUNCH_PID").pipe(Config.withDefault(""))) !== String(process.pid)) return
|
|
80
|
+
const scope = yield* Effect.scope
|
|
81
|
+
const reported = yield* Ref.make("")
|
|
82
|
+
yield* pi.handle("agent_end", (_e, ctx) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const sessionId = ctx.sessionManager.getSessionId()
|
|
85
|
+
if (ctx.mode !== "tui" || (yield* Ref.get(reported)) === sessionId) return
|
|
86
|
+
const reportedAt = yield* Clock.currentTimeMillis
|
|
87
|
+
// Never starts omacd: `omac launch` already did, and the launch is in omac.db for whichever omacd answers.
|
|
88
|
+
yield* connect(layout.socket).pipe(
|
|
89
|
+
Effect.flatMap((client) => client.CoordinatorSessionReady({ launchId, harness: "pi", sessionId, reportedAt })),
|
|
90
|
+
Effect.scoped,
|
|
91
|
+
Effect.timeout("1 second"),
|
|
92
|
+
Effect.andThen(Ref.set(reported, sessionId)),
|
|
93
|
+
// Unanswered is not unsaved: omacd may still apply it (and keeps the newest either way).
|
|
94
|
+
Effect.catchCause((cause) =>
|
|
95
|
+
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.sync(() => ctx.ui.notify(`omac: the Coordinator session may not have been saved: ${Cause.pretty(cause)}`, "warning")),
|
|
96
|
+
),
|
|
97
|
+
Effect.forkIn(scope),
|
|
98
|
+
)
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** Inbox rows for this pane, as custom messages the model sees but the TUI hides: a turn starts when Pi is idle, otherwise they queue as follow-ups. */
|
|
104
|
+
const coordinator: Role = Layer.effectDiscard(
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const pi = yield* Pi
|
|
107
|
+
yield* guarded(Guard.coordinator)
|
|
108
|
+
// Clear the prompt sections other extensions add for a coding agent. Pi runs these handlers in extension load order,
|
|
109
|
+
// so only sections added by extensions loaded before pi-omac are cleared; ones loaded after it keep theirs.
|
|
110
|
+
// Turns started by inbox rows skip this event and get the base prompt, which never has them.
|
|
111
|
+
yield* pi.handle("before_agent_start", (e) => Effect.sync(() => { e.systemPromptOptions.sections = {} }))
|
|
112
|
+
const launchId = yield* Config.option(Config.String("OMAC_LAUNCH_ID"))
|
|
113
|
+
if (Option.isSome(launchId)) yield* reportSession(launchId.value)
|
|
114
|
+
const paneId = yield* Config.option(Config.String("HERDR_PANE_ID"))
|
|
115
|
+
// Outside a herdr pane there is no inbox to subscribe to.
|
|
116
|
+
if (Option.isNone(paneId)) return
|
|
117
|
+
// Pi children of a Coordinator (print / RPC mode) inherit OMAC_COORDINATOR; only the TUI session takes the rows.
|
|
118
|
+
const started = yield* Deferred.make<ExtensionContext>()
|
|
119
|
+
yield* pi.handle("session_start", (_e, ctx) => (ctx.mode === "tui" ? Deferred.succeed(started, ctx).pipe(Effect.asVoid) : Effect.void))
|
|
120
|
+
const subscribe = withOmac({ paneId: paneId.value }, (client) =>
|
|
121
|
+
client["Inbox.Subscribe"](undefined).pipe(
|
|
122
|
+
Stream.runForEach((content) => pi.sendMessage({ customType: "omac", content, display: false }, { triggerTurn: true, deliverAs: "followUp" })),
|
|
123
|
+
),
|
|
124
|
+
).pipe(
|
|
125
|
+
// The connection dropped or omacd closed the stream (omacd restarted): subscribe again; rows not yet taken stay pending.
|
|
126
|
+
Effect.retry({ while: (e) => e._tag === "RpcClientError", schedule: Schedule.spaced("2 seconds") }),
|
|
127
|
+
Effect.repeat(Schedule.spaced("2 seconds")),
|
|
128
|
+
)
|
|
129
|
+
// Anything else ends the subscription; no handler is running, so tell the user.
|
|
130
|
+
yield* Deferred.await(started).pipe(
|
|
131
|
+
Effect.flatMap((ctx) =>
|
|
132
|
+
subscribe.pipe(
|
|
133
|
+
Effect.catchCause((cause) =>
|
|
134
|
+
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.sync(() => ctx.ui.notify(`omac: inbox subscription ended: ${Cause.pretty(cause)}`, "error")),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
Effect.forkScoped,
|
|
139
|
+
)
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/** The role from the environment: Worker, Coordinator, or nothing at all. */
|
|
144
|
+
export const extension = Layer.unwrap(
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const workerId = Option.getOrUndefined(yield* Config.option(Config.String("OMAC_WORKER_ID")))
|
|
147
|
+
const isCoordinator = Option.contains(yield* Config.option(Config.String("OMAC_COORDINATOR")), "1")
|
|
148
|
+
if (!workerId && !isCoordinator) return Layer.empty
|
|
149
|
+
return workerId ? worker(workerId) : coordinator
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const omacCommand = Layer.effect(
|
|
154
|
+
OmacCommand,
|
|
155
|
+
Config.schema(Schema.fromJsonString(Schema.Array(Schema.String)), "OMAC_COMMAND").pipe(Config.withDefault(["omac"])),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
export const services = Layer.mergeAll(
|
|
159
|
+
Layout.layer,
|
|
160
|
+
NodeChildProcessSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer)),
|
|
161
|
+
NodeFileSystem.layer,
|
|
162
|
+
Layer.succeed(SocketConnector, (endpoint) => NodeSocket.makeNet({ path: endpoint })),
|
|
163
|
+
omacCommand,
|
|
164
|
+
).pipe(Layer.provideMerge(NodePath.layer))
|
|
165
|
+
|
|
166
|
+
export default (api: ExtensionAPI) => Pi.runMain(api, extension.pipe(Layer.provide(services)))
|
package/src/pi.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The Pi extension API as an Effect service. Only this file touches `api`. `runMain` is the entry, like
|
|
2
|
+
// NodeRuntime.runMain: Pi calls the extension factory once per session and awaits it, then awaits `session_shutdown`.
|
|
3
|
+
import type {
|
|
4
|
+
AgentEndEvent,
|
|
5
|
+
AgentSettledEvent,
|
|
6
|
+
BeforeAgentStartEvent,
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
SessionStartEvent,
|
|
10
|
+
ToolCallEvent,
|
|
11
|
+
ToolCallEventResult,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent"
|
|
13
|
+
import { Context, Effect, Exit, FiberSet, Layer, Scope } from "effect"
|
|
14
|
+
|
|
15
|
+
/** The events omac handles: what Pi passes, and what the handler may return to it. */
|
|
16
|
+
interface Events {
|
|
17
|
+
readonly session_start: { readonly event: SessionStartEvent; readonly result: void }
|
|
18
|
+
readonly before_agent_start: { readonly event: BeforeAgentStartEvent; readonly result: void }
|
|
19
|
+
readonly tool_call: { readonly event: ToolCallEvent; readonly result: ToolCallEventResult | undefined }
|
|
20
|
+
readonly agent_end: { readonly event: AgentEndEvent; readonly result: void }
|
|
21
|
+
readonly agent_settled: { readonly event: AgentSettledEvent; readonly result: void }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class Pi extends Context.Service<Pi, {
|
|
25
|
+
/** Handle `event` while the scope is open; Pi awaits the Effect and uses its value, and reports its failure. */
|
|
26
|
+
readonly handle: <K extends keyof Events, E, R>(
|
|
27
|
+
event: K,
|
|
28
|
+
f: (event: Events[K]["event"], ctx: ExtensionContext) => Effect.Effect<Events[K]["result"], E, R>,
|
|
29
|
+
) => Effect.Effect<void, never, R | Scope.Scope>
|
|
30
|
+
readonly sendMessage: (...args: Parameters<ExtensionAPI["sendMessage"]>) => Effect.Effect<void>
|
|
31
|
+
}>()("@omac/pi/Pi") {
|
|
32
|
+
static readonly layer = (api: ExtensionAPI) =>
|
|
33
|
+
Layer.succeed(
|
|
34
|
+
Pi,
|
|
35
|
+
Pi.of({
|
|
36
|
+
handle: <K extends keyof Events, E, R>(event: K, f: (event: Events[K]["event"], ctx: ExtensionContext) => Effect.Effect<Events[K]["result"], E, R>) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const run = yield* FiberSet.makeRuntimePromise<R>()
|
|
39
|
+
// `on` is overloaded per event name; `Events` pins each name to its overload's types.
|
|
40
|
+
const on = api.on as (event: K, handler: (e: Events[K]["event"], ctx: ExtensionContext) => Promise<Events[K]["result"]>) => () => void
|
|
41
|
+
yield* Effect.acquireRelease(Effect.sync(() => on(event, (e, ctx) => run(f(e, ctx)))), (off) => Effect.sync(off))
|
|
42
|
+
}),
|
|
43
|
+
sendMessage: (...args) => Effect.sync(() => api.sendMessage(...args)),
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build `extension` in a scope that lives as long as this session; the factory's promise resolves once it is built
|
|
49
|
+
* (its handlers are registered before Pi goes on to `session_start`), and rejects when that fails, so Pi reports
|
|
50
|
+
* the extension as failed to load. `session_shutdown` closes the scope and waits for the finalizers; that handler
|
|
51
|
+
* stays outside the scope, which would remove it along with everything else.
|
|
52
|
+
*/
|
|
53
|
+
static readonly runMain = <E>(api: ExtensionAPI, extension: Layer.Layer<never, E, Pi>) =>
|
|
54
|
+
Effect.runPromise(
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const scope = yield* Scope.make()
|
|
57
|
+
api.on("session_shutdown", () => Effect.runPromise(Scope.close(scope, Exit.void)))
|
|
58
|
+
yield* Layer.buildWithScope(extension, scope).pipe(Effect.provide(Pi.layer(api)))
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
}
|
package/README.md
DELETED