@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 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-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.