feat(core): tool execute hooks, session instructions, synthetic endpoint
- V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API, core ToolHooks service, and registry wiring so hosted/local tool calls run registered before/after hooks; provider-executed calls are excluded. - Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic` with text/description/metadata plus regenerated Promise/Effect/JS client surfaces. - SessionInstructions service: read tool discovers nearby AGENTS.md walking up to the Location root (exclusive) and injects them as durable synthetic instructions, with lazy history dedup of prior claims. - to-llm-message: stop forwarding synthetic message metadata to the model so bookkeeping annotations stay model-hidden. - schema changelog entry for synthetic metadata and the metadata leak fix.
This commit is contained in:
parent
d544ab4d91
commit
d972aa9d83
25 changed files with 893 additions and 116 deletions
|
|
@ -38,6 +38,7 @@ import { SkillGuidance } from "./skill/guidance"
|
|||
import { Snapshot } from "./snapshot"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { SessionInstructions } from "./session/instructions"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
|
|
@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
|
|||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Reference } from "./reference"
|
|||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolHooks } from "./tool/hooks"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -165,6 +166,7 @@ export const node = makeLocationNode({
|
|||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Reference } from "../reference"
|
|||
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
|
@ -31,6 +32,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
|
|
@ -247,6 +249,47 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
|
||||
)
|
||||
}),
|
||||
after: (callback) =>
|
||||
toolHooks.hook.after((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ export interface Interface {
|
|||
sessionID: SessionSchema.ID
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly revert: {
|
||||
readonly stage: (input: {
|
||||
|
|
@ -547,6 +548,7 @@ const layer = Layer.effect(
|
|||
timestamp: yield* DateTime.now,
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
|
|
|
|||
101
packages/core/src/session/instructions.ts
Normal file
101
packages/core/src/session/instructions.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
export * as SessionInstructions from "./instructions"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { SessionEvent } from "./event"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const InjectedMetadata = Schema.Struct({
|
||||
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const store = yield* SessionStore.Service
|
||||
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier turns after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(injected, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||
const next = new Map(map)
|
||||
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
|
||||
return [newlyClaimed, next]
|
||||
})
|
||||
if (claimed.length === 0) return
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs.readFileStringSafe(path).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : { path, content })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
|
||||
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||
// metadata so it survives across Location layer restarts.
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => file.path).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ load })
|
||||
}),
|
||||
)
|
||||
|
||||
function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) {
|
||||
return Effect.gen(function* () {
|
||||
const history = yield* store.context(sessionID)
|
||||
return new Set(
|
||||
history
|
||||
.filter((message): message is SessionMessage.Synthetic => message.type === "synthetic")
|
||||
.flatMap(
|
||||
(message) =>
|
||||
Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ??
|
||||
[],
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "session-instructions",
|
||||
layer,
|
||||
deps: [EventV2.node, FSUtil.node, SessionStore.node],
|
||||
})
|
||||
|
|
@ -154,6 +154,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
id: event.data.messageID,
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
}),
|
||||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text })]
|
||||
case "skill":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
|
|
|
|||
92
packages/core/src/tool/hooks.ts
Normal file
92
packages/core/src/tool/hooks.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm"
|
||||
|
||||
export interface BeforeEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
export interface AfterEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly before: (
|
||||
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly after: (
|
||||
callback: (event: AfterEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
|
||||
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
|
||||
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
|
||||
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
hook: {
|
||||
before: register(() => beforeHooks, (next) => (beforeHooks = next)),
|
||||
after: register(() => afterHooks, (next) => (afterHooks = next)),
|
||||
},
|
||||
runBefore: (event) => run(beforeHooks, event),
|
||||
runAfter: (event) => run(afterHooks, event),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import { dirname } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Image } from "../image"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
|
@ -14,6 +18,7 @@ import { Tool } from "./tool"
|
|||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
const LocationInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
|
|
@ -34,6 +39,9 @@ const layer = Layer.effectDiscard(
|
|||
const mutation = yield* LocationMutation.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
|
|
@ -83,6 +91,20 @@ const layer = Layer.effectDiscard(
|
|||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful file content read (not directory listings), discover
|
||||
// nearby AGENTS.md walking up to the Location root exclusive and inject them
|
||||
// as durable synthetic instructions. Discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = FSUtil.resolve(target.canonical)
|
||||
const root = FSUtil.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({ targets: [FILENAME], start: dirname(resolved), stop: root })
|
||||
const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
|
||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
return yield* image
|
||||
.normalize(resource, { ...content, encoding: "base64" })
|
||||
|
|
@ -113,5 +135,14 @@ const layer = Layer.effectDiscard(
|
|||
export const node = makeLocationNode({
|
||||
name: "tool/read",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||
deps: [
|
||||
ToolRegistry.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
Image.node,
|
||||
PermissionV2.node,
|
||||
SessionInstructions.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store"
|
|||
import { Wildcard } from "../util/wildcard"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export type ExecuteInput = {
|
||||
|
|
@ -47,6 +48,7 @@ const registryLayer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
|
|
@ -61,7 +63,17 @@ const registryLayer = Layer.effect(
|
|||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const pending = yield* settle(registration.tool, input.call, {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
|
|
@ -72,15 +84,38 @@ const registryLayer = Layer.effect(
|
|||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
if ("result" in pending) return pending
|
||||
const output = pending.output
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
if (result.type === "error")
|
||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||
return bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
let settlement: Settlement
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
? bounded.outputPaths.length > 0
|
||||
? { result, outputPaths: bounded.outputPaths }
|
||||
: { result }
|
||||
: bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: beforeEvent.input,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
outputPaths: settlement.outputPaths,
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
return {
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
|
@ -143,11 +178,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue