@xingular/harness 0.1.0 → 0.1.2

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.
@@ -75,6 +75,7 @@ function fromDescriptor(descriptor: PluginDescriptor, source: PluginSource, atta
75
75
  required: descriptor.required ?? null,
76
76
  manage: descriptor.manage ?? (descriptor.tools?.length ? 'tools' : null),
77
77
  settings: (descriptor.settings ?? []).map((setting) => ({ ...setting, value: settingValue(setting, attachments?.settings[descriptor.id]) })),
78
+ connectorTemplates: descriptor.connectorTemplates ?? [],
78
79
  attachment: switchable ? describe(attachments!, descriptor.id, switchable.on) : null,
79
80
  contributions: [
80
81
  ...(descriptor.code ?? []).map((code) => contribution(descriptor.id, 'code', code.name, { description: code.description, state: on ? 'on' : 'off' })),
@@ -172,7 +173,7 @@ function foundContribution(plugin: string, item: Found, trusted: Trusted, input:
172
173
  return contribution(plugin, kind, name, { ...rest, description: description ?? '', path: item.path })
173
174
  }
174
175
 
175
- const empty = (id: string, title: string, description: string, source: PluginSource, path: string | null): Plugin => ({ id, title, description, version: null, source, path, runsCode: false, trust: 'none', code: null, install: null, extends: [], required: null, manage: 'files', settings: [], attachment: null, contributions: [] })
176
+ const empty = (id: string, title: string, description: string, source: PluginSource, path: string | null): Plugin => ({ id, title, description, version: null, source, path, runsCode: false, trust: 'none', code: null, install: null, extends: [], required: null, manage: 'files', settings: [], connectorTemplates: [], attachment: null, contributions: [] })
176
177
 
177
178
  export function catalogue(input: CatalogueInput): Plugin[] {
178
179
  const plugins = new Map<string, Plugin>()
@@ -209,7 +210,7 @@ export function catalogue(input: CatalogueInput): Plugin[] {
209
210
  const install = item.project ? null : item.packageRoot.startsWith(`${input.packagesDir}/`) ? ({ kind: 'folder', source: item.packageRoot } as const) : item.source ? ({ kind: 'settings', source: item.source } as const) : null
210
211
  // Only parts that exist can be extended; a made-up name is dropped.
211
212
  const extended = manifest.extends.filter((part) => input.descriptors.some((descriptor) => descriptor.id === part))
212
- plugin = { ...empty(id, manifest.name, manifest.description, item.project ? 'project' : 'package', item.packageRoot), version: manifest.version, install, extends: extended }
213
+ plugin = { ...empty(id, manifest.name, manifest.description, item.project ? 'project' : 'package', item.packageRoot), version: manifest.version, install, extends: extended, connectorTemplates: manifest.connectorTemplates }
213
214
  } else if (item.origin === 'home') plugin = empty(id, 'Home folder skills', 'Skills in the .agents folder of your home folder. Pi reads them for every account.', 'home', dirname(dirname(item.path)))
214
215
  else if (item.origin === 'project') plugin = empty(id, 'Project folder', 'Found in the working folder: its .pi and .agents folders, and the instruction files Pi reads on the way up from it.', 'project', input.cwd)
215
216
  else plugin = empty(id, 'Shared Pi folder', 'Extensions, skills, prompts and instruction files in the folder every account shares.', 'shared', input.sharedDir)
@@ -1,8 +1,10 @@
1
1
  // What a package says about itself in its `package.json`: Pi's own fields, and under `harness`
2
2
  // which part of the harness it extends and the file of hooks the harness runs for it.
3
3
  import { basename, join } from 'node:path'
4
+ import { z } from 'zod'
4
5
 
5
6
  import { readJsonOr, safeRelative } from '../core/fs'
7
+ import type { ConnectorTemplate } from './types'
6
8
 
7
9
  export interface PackageManifest {
8
10
  name: string
@@ -12,6 +14,8 @@ export interface PackageManifest {
12
14
  extends: string[]
13
15
  /** The hooks file, inside the package, or `null` when it has none. */
14
16
  hooks: string | null
17
+ /** Data-only remote connector forms. Invalid templates are ignored; none can carry credentials. */
18
+ connectorTemplates: ConnectorTemplate[]
15
19
  }
16
20
 
17
21
  interface Raw {
@@ -21,6 +25,30 @@ interface Raw {
21
25
  harness?: unknown
22
26
  }
23
27
 
28
+ const connectorTemplateSchema = z.object({
29
+ id: z.string().regex(/^[a-z0-9-]+$/),
30
+ name: z.string().min(1).max(120),
31
+ description: z.string().max(500),
32
+ url: z.string().url().startsWith('https://'),
33
+ auth: z.enum(['none', 'headers', 'oauth']),
34
+ oauthClient: z.enum(['dynamic', 'user']).nullable(),
35
+ oauthScopes: z.array(z.string().max(500)).max(30),
36
+ docsUrl: z.string().url().startsWith('https://').nullable(),
37
+ fields: z.array(z.object({
38
+ header: z.string().regex(/^[A-Za-z0-9_-]+$/),
39
+ label: z.string().min(1).max(120),
40
+ prefix: z.string().max(100),
41
+ help: z.string().max(500)
42
+ }).strict()).max(12),
43
+ setup: z.array(z.string().max(1000)).max(20),
44
+ notice: z.string().max(1000).nullable()
45
+ }).strict().superRefine((template, context) => {
46
+ const oauth = template.auth === 'oauth'
47
+ const headers = template.auth === 'headers'
48
+ if (oauth !== (template.oauthClient !== null)) context.addIssue({ code: 'custom', message: 'OAuth templates must state a client type and other templates must not.', path: ['oauthClient'] })
49
+ if (headers !== (template.fields.length > 0)) context.addIssue({ code: 'custom', message: 'Header templates must declare input fields and other templates must not.', path: ['fields'] })
50
+ })
51
+
24
52
  /** A path inside the package, or `null` for one that is missing, absolute, or climbs out of it. */
25
53
  function inside(path: unknown): string | null {
26
54
  if (typeof path !== 'string' || !path.trim()) return null
@@ -33,12 +61,16 @@ function inside(path: unknown): string | null {
33
61
 
34
62
  export function packageManifest(packageRoot: string): PackageManifest {
35
63
  const manifest = readJsonOr<Raw>(join(packageRoot, 'package.json'), {})
36
- const own = (manifest.harness && typeof manifest.harness === 'object' && !Array.isArray(manifest.harness) ? manifest.harness : {}) as { extends?: unknown; hooks?: unknown }
64
+ const own = (manifest.harness && typeof manifest.harness === 'object' && !Array.isArray(manifest.harness) ? manifest.harness : {}) as { extends?: unknown; hooks?: unknown; connectors?: unknown }
37
65
  return {
38
66
  name: typeof manifest.name === 'string' && manifest.name ? manifest.name : basename(packageRoot),
39
67
  description: typeof manifest.description === 'string' ? manifest.description : '',
40
68
  version: typeof manifest.version === 'string' ? manifest.version : null,
41
69
  extends: (Array.isArray(own.extends) ? own.extends : typeof own.extends === 'string' ? [own.extends] : []).filter((id): id is string => typeof id === 'string' && !!id),
42
- hooks: inside(own.hooks)
70
+ hooks: inside(own.hooks),
71
+ connectorTemplates: (Array.isArray(own.connectors) ? own.connectors : []).flatMap((value) => {
72
+ const parsed = connectorTemplateSchema.safeParse(value)
73
+ return parsed.success ? [parsed.data] : []
74
+ })
43
75
  }
44
76
  }
@@ -78,6 +78,8 @@ export function packageFolders(directory: string | null): string[] {
78
78
  export interface LoadingInput extends ResolveInput {
79
79
  /** Who the session is for. Something switched off for this agent or project does not load. */
80
80
  caller?: AttachScope
81
+ /** The contributions frozen for a run. When present, this snapshot governs loading. */
82
+ contributions?: readonly string[]
81
83
  }
82
84
 
83
85
  /** Which code a person trusted: the working folder's as a whole, and each installed package's. */
@@ -142,8 +144,11 @@ export class Plugins {
142
144
  if (item.kind === 'hook') return !attached(choices, [pluginIdOf(item)], {}, true)
143
145
  return !attached(choices, foundIds(item), input.caller ?? {}, true)
144
146
  }
145
- const detached = found.filter(off).map((item) => item.path)
146
- const chosen = found.filter((item) => loads(item, trusted) && !detached.includes(item.path))
147
+ const allowed = (item: Found): boolean => input.contributions === undefined
148
+ ? !off(item)
149
+ : input.contributions.includes(foundIds(item)[0])
150
+ const detached = found.filter((item) => !allowed(item)).map((item) => item.path)
151
+ const chosen = found.filter((item) => loads(item, trusted) && allowed(item))
147
152
  const paths = (kind: Found['kind'], builtin: boolean): string[] => chosen.filter((item) => item.kind === kind && (item.origin === 'builtin') === builtin).map((item) => item.path)
148
153
  return {
149
154
  extensionPaths: paths('extension', false),
@@ -154,7 +159,7 @@ export class Plugins {
154
159
  hooks: chosen.filter((item) => item.kind === 'hook' && item.packageRoot).map((item) => ({ path: item.path, packageRoot: item.packageRoot!, hash: packageCodeState[item.packageRoot!]?.hash ?? '' })),
155
160
  found,
156
161
  detached,
157
- resourceAllowed: (item) => !off(item),
162
+ resourceAllowed: allowed,
158
163
  projectCode: projectCodeState,
159
164
  packageCode: packageCodeState
160
165
  }
@@ -65,6 +65,8 @@ export interface Plugin {
65
65
  manage: PluginManage | null
66
66
  /** Numbers a person can set on it, each with its current value. */
67
67
  settings: (PluginSetting & { value: number })[]
68
+ /** Declarative connector setup templates; listing one never creates or enables a connection. */
69
+ connectorTemplates: ConnectorTemplate[]
68
70
  /** Where the whole plugin is switched on and off, or `null` when only its parts are, or nothing is. */
69
71
  attachment: Attachment | null
70
72
  contributions: Contribution[]
@@ -80,6 +82,21 @@ export interface PluginSetting {
80
82
  max: number
81
83
  }
82
84
 
85
+ /** A remote connector setup form. It contains no endpoint credentials or saved connection state. */
86
+ export interface ConnectorTemplate {
87
+ id: string
88
+ name: string
89
+ description: string
90
+ url: string
91
+ auth: 'none' | 'headers' | 'oauth'
92
+ oauthClient: 'dynamic' | 'user' | null
93
+ oauthScopes: string[]
94
+ docsUrl: string | null
95
+ fields: { header: string; label: string; prefix: string; help: string }[]
96
+ setup: string[]
97
+ notice: string | null
98
+ }
99
+
83
100
  /** What a part of the harness, or the embedding application, says about itself. Data only. */
84
101
  export interface PluginDescriptor {
85
102
  id: string
@@ -97,6 +114,8 @@ export interface PluginDescriptor {
97
114
  switchable?: { on: boolean }
98
115
  /** Numbers a person can set on it. */
99
116
  settings?: PluginSetting[]
117
+ /** Remote connector setup templates; users must add, configure and enable each connection. */
118
+ connectorTemplates?: ConnectorTemplate[]
100
119
  /** Code of its own that runs in a session. */
101
120
  code?: { name: string; description: string }[]
102
121
  }
@@ -405,10 +405,10 @@ function hostSection(host: HostSection, kept: boolean): Section | null {
405
405
  }
406
406
 
407
407
  /** Compiles the pack for a caller. It only reads the data root. */
408
- export function compile(env: HarnessEnv, context: CallerContext, launch: Launch = {}): ContextPack {
408
+ export function compile(env: HarnessEnv, context: CallerContext, launch: Launch = {}, contributions?: readonly string[]): ContextPack {
409
409
  const { root } = env
410
410
  const wiki = store.wikiDir(root)
411
- const active = registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null)
411
+ const active = registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null, contributions)
412
412
  const groups = toolGroups(context, launch)
413
413
  const contextProfile = profileFor(root, context)
414
414
  const keep = (id: string): boolean => id === 'references' || keeps(contextProfile, id)
@@ -573,8 +573,8 @@ function sameSection(left: Section | undefined, right: Section | undefined): boo
573
573
  * Recording the current pack does not mark an update delivered: the session saves that snapshot
574
574
  * only when it appends the corresponding context message.
575
575
  */
576
- export function sessionLaunch(env: HarnessEnv, context: CallerContext, launch: Launch, historyFile: string | null): { system: string; update: ContextUpdate | null } {
577
- const pack = compile(env, context, launch)
576
+ export function sessionLaunch(env: HarnessEnv, context: CallerContext, launch: Launch, historyFile: string | null, contributions?: readonly string[]): { system: string; update: ContextUpdate | null } {
577
+ const pack = compile(env, context, launch, contributions)
578
578
  const dynamic = [...BUILT_IN_DYNAMIC, ...(launch.sections ?? []).filter((host) => host.dynamic).map((host) => ({ id: host.id, order: host.order }))]
579
579
  .map((slot, index) => ({ ...slot, index }))
580
580
  .sort((left, right) => left.order - right.order || left.index - right.index)
@@ -10,6 +10,7 @@ import { HarnessError, invalid } from '../core/errors'
10
10
  import { projectsOrNone } from '../core/projects'
11
11
  import * as capabilities from '../capabilities/api'
12
12
  import { loadRun as loadCapabilitySnapshot } from '../capabilities/runSnapshot'
13
+ import { capabilitySet } from '../capabilities/runSnapshot'
13
14
  import type { Harness } from '../harness'
14
15
  import * as blocks from '../memory/blocks'
15
16
  import { validateScopeIds } from '../mcp/connections'
@@ -255,6 +256,11 @@ export function methods(harness: Harness): Record<string, Handler> {
255
256
  'capabilities.detach': (params: Params) => capabilities.switchCapability(harness, { ...pluginScope(params), id: text(params, 'id'), scope: params.scope ?? null }, false),
256
257
  'capabilities.resolve': (params: Params) => capabilities.resolve(harness, { ...pluginScope(params), tools: Array.isArray(params.tools) ? params.tools.filter((item: unknown): item is string => typeof item === 'string') : [] }),
257
258
  'capabilities.run': (params: Params) => loadCapabilitySnapshot(root(), text(params, 'runId')),
259
+ 'capabilities.intersect': (params: Params) => capabilities.toolIntersection(
260
+ capabilitySet(params.parent),
261
+ capabilitySet(params.assignee),
262
+ params.tools === undefined ? undefined : Array.isArray(params.tools) ? params.tools.filter((item: unknown): item is string => typeof item === 'string') : []
263
+ ),
258
264
 
259
265
  // ---- Runs --------------------------------------------------------------------------------------
260
266
  'runs.load': (params: Params) => snapshot(root(), text(params, 'runId')),
@@ -83,15 +83,15 @@ export interface SessionsOptions {
83
83
  /** Chooses what a session loads. Without it Pi discovers extensions and skills on its own. */
84
84
  plugins?: Plugins
85
85
  /** Resolves the caller's capability set from the harness's stores. */
86
- resolveCapabilities?: (caller: CallerContext, tools: readonly string[]) => Promise<CapabilitySet>
86
+ resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
87
87
  factory?: SessionFactory
88
88
  }
89
89
 
90
90
  /** The harness's own plugins a person switched on for this caller, with the numbers they set. */
91
- function firstPartyFor(root: string, caller: CallerContext): InlineExtension[] {
91
+ function firstPartyFor(root: string, contributions: readonly string[]): InlineExtension[] {
92
92
  const choices = attachments.load(root)
93
- const scope = { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }
94
- return firstPartyExtensions((id) => attachments.attached(choices, [id], scope, FIRST_PARTY_PLUGINS.find((plugin) => plugin.id === id)?.switchable?.on ?? false), choices.settings)
93
+ const allowed = new Set(contributions)
94
+ return firstPartyExtensions((id) => FIRST_PARTY_PLUGINS.find((plugin) => plugin.id === id)?.code?.some((code) => allowed.has(`${id}/code/${code.name}`)) ?? false, choices.settings)
95
95
  }
96
96
 
97
97
  export class Sessions {
@@ -161,7 +161,7 @@ export class Sessions {
161
161
  ...(broker?.toolNames ?? [])
162
162
  ])]
163
163
  const available = this.options.resolveCapabilities
164
- ? await this.options.resolveCapabilities(caller, requestedTools)
164
+ ? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
165
165
  : { version: 1 as const, tools: requestedTools, connectors: [], contributions: [] }
166
166
  const requested = input.capabilities ? parseCapabilitySet(input.capabilities) : available
167
167
  const capabilities: CapabilitySet = {
@@ -222,7 +222,7 @@ export class Sessions {
222
222
  connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
223
223
  toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
224
224
  }
225
- ;({ system, update } = sessionLaunch(this.env, caller, effectiveLaunch, historyFile))
225
+ ;({ system, update } = sessionLaunch(this.env, caller, effectiveLaunch, historyFile, capabilities.contributions))
226
226
  } catch (error) {
227
227
  fail(error)
228
228
  throw error
@@ -239,7 +239,7 @@ export class Sessions {
239
239
  try {
240
240
  const tools: ToolSpec[] = [
241
241
  ...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
242
- ...memoryTools(this.options.env, caller),
242
+ ...memoryTools(this.options.env, caller, capabilities.contributions),
243
243
  askUserTool(run),
244
244
  pluginProposeTool(this.options.env, caller)
245
245
  ]
@@ -257,9 +257,9 @@ export class Sessions {
257
257
  preamble: input.preamble ?? null,
258
258
  automatic: input.automatic,
259
259
  account: context,
260
- resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null),
261
- inline: [...firstPartyFor(root, caller), runLineageJoin(root, runId)],
262
- ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null } }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
260
+ resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
261
+ inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
262
+ ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
263
263
  system,
264
264
  update
265
265
  }
@@ -31,6 +31,18 @@ export interface RegistryItem {
31
31
  builtin?: true
32
32
  }
33
33
 
34
+ /** The capability id used when this registry item is shown in the plugin catalogue. */
35
+ export function contributionId(item: Pick<RegistryItem, 'id' | 'builtin'>): string {
36
+ return `${item.builtin ? 'included' : 'yours'}/${item.id}`
37
+ }
38
+
39
+ /** Limits registry items to the contributions frozen for a run. `undefined` means no limit. */
40
+ export function limitToContributions(items: readonly RegistryItem[], contributions?: readonly string[]): RegistryItem[] {
41
+ if (contributions === undefined) return [...items]
42
+ const allowed = new Set(contributions)
43
+ return items.filter((item) => allowed.has(contributionId(item)))
44
+ }
45
+
34
46
  export interface SkillBundleInput {
35
47
  name: string
36
48
  files: { path: string; content: string; encoding: 'utf8' | 'base64' }[]
@@ -138,7 +150,8 @@ export const isItemId = (id: string): boolean => KINDS.some((kind) => id.startsW
138
150
  * Items active for a caller. A built-in is on until it is switched off; a person's own is off
139
151
  * until it is switched on, everywhere, for the caller's project, or for the calling agent.
140
152
  */
141
- export function enabledFor(root: string, projectId: string | null, agentId: string | null = null): RegistryItem[] {
153
+ export function enabledFor(root: string, projectId: string | null, agentId: string | null = null, contributions?: readonly string[]): RegistryItem[] {
154
+ if (contributions !== undefined) return limitToContributions(list(root), contributions)
142
155
  const choices = attachments.load(root)
143
156
  return list(root).filter((item) => attachments.attached(choices, [item.id], { agentId, projectId }, !!item.builtin))
144
157
  }
@@ -160,8 +173,8 @@ export interface RegistryResources {
160
173
  }
161
174
 
162
175
  /** What a Pi session needs to load enabled items natively. Rule text reaches the model through the compiled prompt. */
163
- export function resources(root: string, projectId: string | null, agentId: string | null = null): RegistryResources {
164
- const items = enabledFor(root, projectId, agentId)
176
+ export function resources(root: string, projectId: string | null, agentId: string | null = null, contributions?: readonly string[]): RegistryResources {
177
+ const items = enabledFor(root, projectId, agentId, contributions)
165
178
  return {
166
179
  ids: items.map((item) => item.id),
167
180
  skillPaths: items.filter((item) => item.kind === 'skills').map((item) => item.path),
@@ -86,7 +86,8 @@ export default async function (pi: ExtensionAPI) {
86
86
  if (command?.type === 'interrupt') context?.abort()
87
87
  else if (command?.type === 'send') {
88
88
  if (context?.ui.getEditorText().trim()) throw new Error('The terminal contains a user draft.')
89
- pi.sendUserMessage(command.text!)
89
+ // Pi 1.2 needs deliverAs while a run is busy; a message sent then waits for that run to finish.
90
+ pi.sendUserMessage(command.text!, context?.isIdle() === false ? { deliverAs: 'followUp' } : undefined)
90
91
  }
91
92
  })
92
93
  .catch(() => {
@@ -104,7 +105,8 @@ export default async function (pi: ExtensionAPI) {
104
105
  usage = emptyUsage()
105
106
  reasoningTokens = undefined
106
107
  costReported = false
107
- await terminalCall('begin', { text: event.prompt })
108
+ // A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
109
+ await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' })
108
110
  })
109
111
  pi.on('message_end', async (event) => {
110
112
  if (event.message.role !== 'assistant') return
@@ -4,7 +4,7 @@ import type { CallerContext } from '../memory/caller'
4
4
  import { describeTool, WIKI_AGENT_TOOLS } from './definitions'
5
5
  import type { ToolSpec } from './types'
6
6
 
7
- export function memoryTools(env: DispatchEnv, context: CallerContext): ToolSpec[] {
7
+ export function memoryTools(env: DispatchEnv, context: CallerContext, contributions?: readonly string[]): ToolSpec[] {
8
8
  return WIKI_AGENT_TOOLS.map((tool) => ({
9
9
  name: tool.name,
10
10
  label: tool.label,
@@ -12,7 +12,7 @@ export function memoryTools(env: DispatchEnv, context: CallerContext): ToolSpec[
12
12
  parameters: (Type) => Type.Unsafe(tool.parameters),
13
13
  execute: async (input) => {
14
14
  try {
15
- return dispatch(env, context, tool.call, input) || 'Nothing found.'
15
+ return dispatch(env, context, tool.call, input, contributions) || 'Nothing found.'
16
16
  } catch (error) {
17
17
  // The model sees the reason and can adjust, rather than the run failing.
18
18
  return `The Wiki could not do that: ${error instanceof Error ? error.message : String(error)}`