pi2web 0.2.0 → 0.2.1

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.
@@ -1,6 +1,9 @@
1
- import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
1
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { createCollabExtension } from './collab/pi-extension.js';
5
+ /** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
6
+ const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
4
7
  /** Real in-process Pi SDK backend with session/model/extension controls exposed to remote clients. */
5
8
  export class SdkBackend {
6
9
  session;
@@ -14,9 +17,14 @@ export class SdkBackend {
14
17
  this.agentId = agentId;
15
18
  this.cwd = cwd;
16
19
  }
17
- static async create(agentId, cwd, sessionFile) {
20
+ static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge) {
18
21
  const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
19
- const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager });
22
+ // This factory is deliberately inline instead of auto-discovered: normal Pi and normal pi2web agents
23
+ // never load the collaboration tools or their collaboration-specific system guidance.
24
+ const resourceLoader = profile === 'collab' && collabBridge ? new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), extensionFactories: [{ name: 'pi2web-collab', factory: createCollabExtension(agentId, collabBridge) }] }) : undefined;
25
+ if (resourceLoader)
26
+ await resourceLoader.reload();
27
+ const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager, resourceLoader });
20
28
  if (modelFallbackMessage && !session.state.model) {
21
29
  session.dispose();
22
30
  throw Object.assign(new Error(modelFallbackMessage), { code: 'PI_MODEL_UNAVAILABLE' });
@@ -25,23 +33,54 @@ export class SdkBackend {
25
33
  await session.bindExtensions({ uiContext: backend.extensionUi() });
26
34
  return backend;
27
35
  }
36
+ // Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
37
+ // so `/name args` typed in the web composer behaves exactly like the Pi TUI.
28
38
  prompt(message) { return this.session.prompt(message); }
29
- steer(message) { return this.session.steer(message); }
30
- followUp(message) { return this.session.followUp(message); }
39
+ // Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
40
+ // or waits for it (`queued`); the web composer shows that difference instead of guessing.
41
+ async steer(message) { return normalizeDisposition(await this.session.steer(message)); }
42
+ async followUp(message) { return normalizeDisposition(await this.session.followUp(message)); }
43
+ async getQueue() { return { steering: [...this.session.getSteeringMessages()], followUp: [...this.session.getFollowUpMessages()] }; }
44
+ /** Drop queued steering and follow-up messages and hand them back so a client can restore them to its composer. */
45
+ async clearQueue() { const cleared = this.session.clearQueue(); return { steering: [...cleared.steering], followUp: [...cleared.followUp] }; }
31
46
  abort() { return this.session.abort(); }
32
47
  async getState() { return { agentId: this.agentId, sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, cwd: this.cwd, status: this.disposed ? 'stopped' : this.session.isStreaming ? 'streaming' : 'idle' }; }
33
48
  async getMessages() { return [...this.session.state.messages]; }
34
49
  async getCapabilities() {
35
50
  const current = this.session.model;
36
- const available = this.session.modelRegistry.getAvailable();
37
- return { model: current ? this.modelInfo(current) : null, models: available.map(model => this.modelInfo(model)), thinkingLevel: this.session.thinkingLevel, thinkingLevels: this.session.getAvailableThinkingLevels(), supportsThinking: this.session.supportsThinking() };
51
+ // ModelRuntime.getAvailable() is async since 0.80.8 and may consult provider auth; the snapshot keeps
52
+ // an unauthenticated or offline call from blocking the capabilities endpoint.
53
+ const available = await this.session.modelRuntime.getAvailable().catch(() => this.session.modelRuntime.getAvailableSnapshot());
54
+ return { model: current ? this.modelInfo(current) : null, models: available.map((model) => this.modelInfo(model)), thinkingLevel: this.session.thinkingLevel, thinkingLevels: this.session.getAvailableThinkingLevels(), supportsThinking: this.session.supportsThinking() };
38
55
  }
56
+ /** Slash commands the composer can offer: file-based prompt templates and skills discovered for this cwd. */
57
+ async listCommands() {
58
+ const loader = this.session.resourceLoader;
59
+ const prompts = loader.getPrompts().prompts.map(prompt => ({ name: prompt.name, description: prompt.description, argumentHint: prompt.argumentHint, source: 'prompt' }));
60
+ const skills = loader.getSkills().skills.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, source: 'skill' }));
61
+ return [...prompts, ...skills].sort((a, b) => a.name.localeCompare(b.name));
62
+ }
63
+ /** Run a command inside the Agent session so its output is recorded in the transcript (`!!` keeps it out of the model context). */
64
+ async runBash(command, excludeFromContext = false) {
65
+ const id = randomUUID();
66
+ this.events.emit('event', { type: 'bash_execution_start', id, command });
67
+ try {
68
+ const result = await this.session.executeBash(command, undefined, { excludeFromContext, id });
69
+ this.events.emit('event', { type: 'bash_execution_end', id, command, ...result });
70
+ return { id, ...result };
71
+ }
72
+ catch (error) {
73
+ this.events.emit('event', { type: 'bash_execution_end', id, command, isError: true, errorMessage: error instanceof Error ? error.message : String(error) });
74
+ throw error;
75
+ }
76
+ }
77
+ abortBash() { this.session.abortBash(); }
39
78
  async getSession() { return { ...await this.getSessionInfo(), leafId: this.session.sessionManager.getLeafId(), entries: this.session.sessionManager.getEntries(), tree: this.session.sessionManager.getTree(), userMessages: this.session.getUserMessagesForForking() }; }
40
79
  async getSessionInfo() { return { sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, stats: this.session.getSessionStats(), contextUsage: this.session.getContextUsage() }; }
41
80
  compact(instructions) { return this.session.compact(instructions); }
42
- async setModel(provider, modelId) { const model = this.session.modelRegistry.find(provider, modelId); if (!model)
81
+ async setModel(provider, modelId) { const model = this.session.modelRuntime.getModel(provider, modelId); if (!model)
43
82
  throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' }); await this.session.setModel(model); }
44
- async setThinkingLevel(level) { if (!['off', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(level))
83
+ async setThinkingLevel(level) { if (level !== 'off' && !this.session.getAvailableThinkingLevels().includes(level))
45
84
  throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' }); this.session.setThinkingLevel(level); }
46
85
  async setSessionName(name) { const value = name.trim(); if (!value || value.length > 200)
47
86
  throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' }); this.session.setSessionName(value); }
@@ -56,17 +95,41 @@ export class SdkBackend {
56
95
  throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
57
96
  sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), { parentSession: current }).getSessionFile();
58
97
  } return { sessionFile, selectedText }; }
59
- async extensionResponse(requestId, value) { const pending = this.pendingUi.get(requestId); if (!pending)
60
- throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' }); clearTimeout(pending.timer); this.pendingUi.delete(requestId); pending.resolve(value); }
98
+ async extensionResponse(requestId, value) {
99
+ if (!this.resolveUiRequest(requestId, value))
100
+ throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
101
+ }
61
102
  subscribe(listener) { const fromSession = (event) => listener(event); const unsubscribe = this.session.subscribe(fromSession); this.events.on('event', listener); return () => { unsubscribe(); this.events.off('event', listener); }; }
62
- async dispose() { if (this.disposed)
63
- return; for (const [id, pending] of this.pendingUi) {
64
- clearTimeout(pending.timer);
65
- pending.resolve(undefined);
66
- this.pendingUi.delete(id);
67
- } await this.session.abort(); this.session.dispose(); this.disposed = true; }
103
+ async dispose() {
104
+ if (this.disposed)
105
+ return;
106
+ for (const requestId of this.pendingUi.keys())
107
+ this.resolveUiRequest(requestId, undefined);
108
+ await this.session.abort();
109
+ this.session.dispose();
110
+ this.disposed = true;
111
+ }
68
112
  modelInfo(model) { return { provider: model.provider, id: model.id, name: model.name, reasoning: !!model.reasoning, contextWindow: model.contextWindow, maxTokens: model.maxTokens }; }
69
- request(kind, payload) { const requestId = randomUUID(); return new Promise(resolve => { const timer = setTimeout(() => { this.pendingUi.delete(requestId); resolve(undefined); }, 5 * 60_000); this.pendingUi.set(requestId, { resolve, timer }); this.events.emit('event', { type: 'extension_ui_request', requestId, kind, ...payload }); }); }
113
+ resolveUiRequest(requestId, value) {
114
+ const pending = this.pendingUi.get(requestId);
115
+ if (!pending)
116
+ return false;
117
+ clearTimeout(pending.timer);
118
+ this.pendingUi.delete(requestId);
119
+ // Publish before resuming the extension: every client sees the same order,
120
+ // including requests answered elsewhere, timed out, or closed during disposal.
121
+ this.events.emit('event', { type: 'extension_ui_response', requestId });
122
+ pending.resolve(value);
123
+ return true;
124
+ }
125
+ request(kind, payload) {
126
+ const requestId = randomUUID();
127
+ return new Promise(resolve => {
128
+ const timer = setTimeout(() => this.resolveUiRequest(requestId, undefined), 5 * 60_000);
129
+ this.pendingUi.set(requestId, { resolve, timer });
130
+ this.events.emit('event', { type: 'extension_ui_request', requestId, kind, ...payload });
131
+ });
132
+ }
70
133
  extensionUi() {
71
134
  return {
72
135
  select: async (title, options, opts) => await this.request('select', { title, options, opts }),
@@ -82,4 +145,4 @@ export class SdkBackend {
82
145
  };
83
146
  }
84
147
  }
85
- export const createSdkBackend = (agentId, cwd, sessionFile) => SdkBackend.create(agentId, cwd, sessionFile);
148
+ export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge);