@co0ontty/wand 4.81.0 → 4.82.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/dist/agent-dispatch.js +7 -4
- package/dist/ai-team-prompts.d.ts +1 -1
- package/dist/ai-team-prompts.js +2 -2
- package/dist/ai-team-runner.d.ts +2 -0
- package/dist/ai-team-runner.js +60 -7
- package/dist/ai-team-types.d.ts +6 -0
- package/dist/ai-team-types.js +11 -0
- package/dist/build-info.json +3 -3
- package/dist/cli.js +11 -0
- package/dist/config.js +2 -0
- package/dist/default-employee.d.ts +11 -0
- package/dist/default-employee.js +33 -0
- package/dist/employee-knowledge-cli.d.ts +2 -0
- package/dist/employee-knowledge-cli.js +52 -0
- package/dist/employee-knowledge-content.d.ts +4 -0
- package/dist/employee-knowledge-content.js +23 -0
- package/dist/employee-knowledge-types.d.ts +15 -0
- package/dist/employee-knowledge-types.js +3 -0
- package/dist/employee-knowledge.d.ts +6 -0
- package/dist/employee-knowledge.js +84 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +121 -58
- package/dist/iteration-log.d.ts +5 -0
- package/dist/iteration-log.js +12 -1
- package/dist/message-truncator.js +13 -9
- package/dist/models.d.ts +4 -0
- package/dist/models.js +38 -10
- package/dist/pi-model-discovery.d.ts +12 -0
- package/dist/pi-model-discovery.js +281 -0
- package/dist/process-manager.d.ts +23 -1
- package/dist/process-manager.js +68 -1
- package/dist/prompt-optimizer.js +10 -11
- package/dist/pty-turn-activity.d.ts +27 -0
- package/dist/pty-turn-activity.js +31 -0
- package/dist/server-employee-routes.js +52 -6
- package/dist/server-session-routes.js +11 -5
- package/dist/server-task-routes.js +6 -1
- package/dist/server-user-memory.d.ts +9 -0
- package/dist/server-user-memory.js +96 -0
- package/dist/server.js +22 -2
- package/dist/session-topic.js +6 -5
- package/dist/silicon-employee-draft.js +1 -3
- package/dist/storage.d.ts +38 -0
- package/dist/storage.js +276 -3
- package/dist/structured-claude-adapter.d.ts +1 -1
- package/dist/structured-claude-adapter.js +2 -0
- package/dist/structured-provider-common.d.ts +2 -2
- package/dist/structured-provider-common.js +6 -6
- package/dist/structured-session-manager.js +5 -4
- package/dist/task-title.js +8 -7
- package/dist/team-report-file-link.d.ts +6 -0
- package/dist/team-report-file-link.js +54 -0
- package/dist/team-report-preview.d.ts +3 -0
- package/dist/team-report-preview.js +70 -0
- package/dist/types.d.ts +15 -0
- package/dist/user-memory-types.d.ts +42 -0
- package/dist/user-memory-types.js +6 -0
- package/dist/user-memory.d.ts +30 -0
- package/dist/user-memory.js +190 -0
- package/dist/web-ui/content/ai-teams.js +13 -13
- package/dist/web-ui/content/scripts.js +59 -61
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
5
|
+
const SUPPORTED_APIS = new Set([
|
|
6
|
+
"anthropic-messages",
|
|
7
|
+
"google-generative-ai",
|
|
8
|
+
"openai-completions",
|
|
9
|
+
"openai-responses",
|
|
10
|
+
]);
|
|
11
|
+
const MAX_PAGES_PER_ENDPOINT = 40;
|
|
12
|
+
const MAX_MODELS_PER_ENDPOINT = 2000;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function resolveConfigValue(value, env) {
|
|
19
|
+
if (typeof value !== "string")
|
|
20
|
+
return null;
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed || trimmed.startsWith("!"))
|
|
23
|
+
return null;
|
|
24
|
+
const dollarEscape = "\u0000";
|
|
25
|
+
const bangEscape = "\u0001";
|
|
26
|
+
let missingReference = false;
|
|
27
|
+
const expanded = trimmed
|
|
28
|
+
.replace(/\$\$/g, dollarEscape)
|
|
29
|
+
.replace(/\$!/g, bangEscape)
|
|
30
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
31
|
+
const resolved = env[braced ?? bare ?? ""];
|
|
32
|
+
if (resolved === undefined) {
|
|
33
|
+
missingReference = true;
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
})
|
|
38
|
+
.replaceAll(dollarEscape, "$")
|
|
39
|
+
.replaceAll(bangEscape, "!");
|
|
40
|
+
return missingReference || !expanded.trim() ? null : expanded;
|
|
41
|
+
}
|
|
42
|
+
function configuredHeaders(value, env) {
|
|
43
|
+
if (!isRecord(value))
|
|
44
|
+
return {};
|
|
45
|
+
const headers = {};
|
|
46
|
+
for (const [name, rawValue] of Object.entries(value)) {
|
|
47
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/.test(name))
|
|
48
|
+
continue;
|
|
49
|
+
const headerValue = resolveConfigValue(rawValue, env);
|
|
50
|
+
if (!headerValue || headerValue.length > 16_384 || /[\r\n]/.test(headerValue))
|
|
51
|
+
continue;
|
|
52
|
+
headers[name] = headerValue;
|
|
53
|
+
}
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
function endpointUrl(baseUrl, api) {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(baseUrl);
|
|
59
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password)
|
|
60
|
+
return null;
|
|
61
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
62
|
+
let suffix;
|
|
63
|
+
if (api === "anthropic-messages") {
|
|
64
|
+
suffix = /\/v\d+(?:beta)?$/i.test(basePath) ? "models" : "v1/models";
|
|
65
|
+
}
|
|
66
|
+
else if (api === "google-generative-ai") {
|
|
67
|
+
suffix = /\/v\d+(?:beta)?$/i.test(basePath) ? "models" : "v1beta/models";
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
suffix = "models";
|
|
71
|
+
}
|
|
72
|
+
url.pathname = `${basePath}/${suffix}`.replace(/\/{2,}/g, "/");
|
|
73
|
+
url.hash = "";
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function providerEndpoints(providersValue, env) {
|
|
81
|
+
if (!isRecord(providersValue))
|
|
82
|
+
return [];
|
|
83
|
+
const endpoints = [];
|
|
84
|
+
for (const [provider, rawProvider] of Object.entries(providersValue)) {
|
|
85
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(provider) || !isRecord(rawProvider))
|
|
86
|
+
continue;
|
|
87
|
+
const providerApi = typeof rawProvider.api === "string" ? rawProvider.api.trim() : "";
|
|
88
|
+
const providerBaseUrl = typeof rawProvider.baseUrl === "string" ? rawProvider.baseUrl.trim() : "";
|
|
89
|
+
const providerApiKey = resolveConfigValue(rawProvider.apiKey, env) ?? "";
|
|
90
|
+
const providerKeyConfigured = typeof rawProvider.apiKey === "string" && !!rawProvider.apiKey.trim();
|
|
91
|
+
const providerHeaders = configuredHeaders(rawProvider.headers, env);
|
|
92
|
+
const models = Array.isArray(rawProvider.models) ? rawProvider.models : [];
|
|
93
|
+
const endpointConfigs = [{
|
|
94
|
+
api: providerApi,
|
|
95
|
+
baseUrl: providerBaseUrl,
|
|
96
|
+
apiKey: providerApiKey,
|
|
97
|
+
apiKeyUnavailable: providerKeyConfigured && !providerApiKey,
|
|
98
|
+
headers: providerHeaders,
|
|
99
|
+
}];
|
|
100
|
+
for (const model of models) {
|
|
101
|
+
if (!isRecord(model))
|
|
102
|
+
continue;
|
|
103
|
+
const api = typeof model.api === "string" && model.api.trim() ? model.api.trim() : providerApi;
|
|
104
|
+
const baseUrl = typeof model.baseUrl === "string" && model.baseUrl.trim()
|
|
105
|
+
? model.baseUrl.trim()
|
|
106
|
+
: providerBaseUrl;
|
|
107
|
+
const rawModelApiKey = model.apiKey;
|
|
108
|
+
const modelApiKey = resolveConfigValue(rawModelApiKey, env);
|
|
109
|
+
const apiKey = modelApiKey ?? providerApiKey;
|
|
110
|
+
const apiKeyUnavailable = typeof rawModelApiKey === "string" && !!rawModelApiKey.trim()
|
|
111
|
+
? !modelApiKey
|
|
112
|
+
: providerKeyConfigured && !providerApiKey;
|
|
113
|
+
const headers = { ...providerHeaders, ...configuredHeaders(model.headers, env) };
|
|
114
|
+
if (api !== providerApi || baseUrl !== providerBaseUrl || apiKey !== providerApiKey
|
|
115
|
+
|| JSON.stringify(headers) !== JSON.stringify(providerHeaders)) {
|
|
116
|
+
endpointConfigs.push({ api, baseUrl, apiKey, apiKeyUnavailable, headers });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const config of endpointConfigs) {
|
|
121
|
+
if (!SUPPORTED_APIS.has(config.api) || !config.baseUrl || config.apiKeyUnavailable)
|
|
122
|
+
continue;
|
|
123
|
+
const url = endpointUrl(config.baseUrl, config.api);
|
|
124
|
+
if (!url)
|
|
125
|
+
continue;
|
|
126
|
+
// Keep this key in memory only; never log or persist endpoint credentials.
|
|
127
|
+
const signature = `${config.api}\n${url.href}\n${config.apiKey}\n${JSON.stringify(config.headers)}`;
|
|
128
|
+
if (seen.has(signature))
|
|
129
|
+
continue;
|
|
130
|
+
seen.add(signature);
|
|
131
|
+
endpoints.push({ provider, ...config });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return endpoints;
|
|
135
|
+
}
|
|
136
|
+
function asModelId(value, api) {
|
|
137
|
+
if (typeof value !== "string")
|
|
138
|
+
return "";
|
|
139
|
+
const trimmed = value.trim();
|
|
140
|
+
if (api === "google-generative-ai")
|
|
141
|
+
return trimmed.replace(/^models\//, "");
|
|
142
|
+
return trimmed;
|
|
143
|
+
}
|
|
144
|
+
function parseEndpointPage(api, body, provider) {
|
|
145
|
+
if (!isRecord(body))
|
|
146
|
+
return { models: [], nextCursor: "" };
|
|
147
|
+
const rows = Array.isArray(body.data)
|
|
148
|
+
? body.data
|
|
149
|
+
: Array.isArray(body.models) ? body.models : [];
|
|
150
|
+
const models = [];
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
if (!isRecord(row))
|
|
153
|
+
continue;
|
|
154
|
+
const id = asModelId(api === "google-generative-ai" ? row.name : row.id, api);
|
|
155
|
+
const qualifiedId = `${provider}/${id}`;
|
|
156
|
+
if (!id || id.length > 128 || !MODEL_ID_PATTERN.test(id) || !MODEL_ID_PATTERN.test(qualifiedId))
|
|
157
|
+
continue;
|
|
158
|
+
const displayName = typeof row.display_name === "string" ? row.display_name.trim()
|
|
159
|
+
: typeof row.displayName === "string" ? row.displayName.trim()
|
|
160
|
+
: typeof row.name === "string" && api !== "google-generative-ai" ? row.name.trim() : "";
|
|
161
|
+
models.push({
|
|
162
|
+
id: qualifiedId,
|
|
163
|
+
label: displayName && displayName !== id ? `${displayName} · ${qualifiedId}` : qualifiedId,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
let nextCursor = "";
|
|
167
|
+
if (api === "google-generative-ai") {
|
|
168
|
+
nextCursor = typeof body.nextPageToken === "string" ? body.nextPageToken : "";
|
|
169
|
+
}
|
|
170
|
+
else if (body.has_more === true) {
|
|
171
|
+
const lastRow = rows.at(-1);
|
|
172
|
+
const lastRowId = isRecord(lastRow) && typeof lastRow.id === "string" ? lastRow.id : "";
|
|
173
|
+
nextCursor = typeof body.last_id === "string" ? body.last_id : lastRowId;
|
|
174
|
+
}
|
|
175
|
+
return { models, nextCursor };
|
|
176
|
+
}
|
|
177
|
+
function pageUrl(endpoint, cursor) {
|
|
178
|
+
const url = endpointUrl(endpoint.baseUrl, endpoint.api);
|
|
179
|
+
if (!url)
|
|
180
|
+
return null;
|
|
181
|
+
if (cursor) {
|
|
182
|
+
const key = endpoint.api === "google-generative-ai" ? "pageToken"
|
|
183
|
+
: endpoint.api === "anthropic-messages" ? "after_id" : "after";
|
|
184
|
+
url.searchParams.set(key, cursor);
|
|
185
|
+
}
|
|
186
|
+
return url;
|
|
187
|
+
}
|
|
188
|
+
async function fetchJson(fetchImpl, url, headers) {
|
|
189
|
+
try {
|
|
190
|
+
const response = await fetchImpl(url, {
|
|
191
|
+
method: "GET",
|
|
192
|
+
headers: { accept: "application/json", ...headers },
|
|
193
|
+
redirect: "error",
|
|
194
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok)
|
|
197
|
+
return null;
|
|
198
|
+
const text = await response.text();
|
|
199
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES)
|
|
200
|
+
return null;
|
|
201
|
+
return JSON.parse(text);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Provider errors can echo private endpoint URLs or request credentials. Discovery is
|
|
205
|
+
// best-effort, so keep failures private and leave the Pi CLI catalog available.
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function fetchEndpointModels(endpoint, fetchImpl) {
|
|
210
|
+
const result = [];
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
let cursor = "";
|
|
213
|
+
for (let page = 0; page < MAX_PAGES_PER_ENDPOINT && result.length < MAX_MODELS_PER_ENDPOINT; page++) {
|
|
214
|
+
const url = pageUrl(endpoint, cursor);
|
|
215
|
+
if (!url)
|
|
216
|
+
break;
|
|
217
|
+
const headers = { ...endpoint.headers };
|
|
218
|
+
if (endpoint.apiKey) {
|
|
219
|
+
if (endpoint.api === "google-generative-ai")
|
|
220
|
+
headers["x-goog-api-key"] = endpoint.apiKey;
|
|
221
|
+
else if (endpoint.api === "anthropic-messages") {
|
|
222
|
+
headers["x-api-key"] = endpoint.apiKey;
|
|
223
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
headers.authorization = `Bearer ${endpoint.apiKey}`;
|
|
227
|
+
}
|
|
228
|
+
const body = await fetchJson(fetchImpl, url, headers);
|
|
229
|
+
if (!body)
|
|
230
|
+
break;
|
|
231
|
+
const parsed = parseEndpointPage(endpoint.api, body, endpoint.provider);
|
|
232
|
+
for (const model of parsed.models) {
|
|
233
|
+
if (seen.has(model.id))
|
|
234
|
+
continue;
|
|
235
|
+
seen.add(model.id);
|
|
236
|
+
result.push(model);
|
|
237
|
+
if (result.length >= MAX_MODELS_PER_ENDPOINT)
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
if (!parsed.nextCursor || parsed.nextCursor === cursor)
|
|
241
|
+
break;
|
|
242
|
+
cursor = parsed.nextCursor;
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Discovers additional chat model IDs exposed by the API endpoints configured in Pi's models.json.
|
|
248
|
+
* The endpoint's key stays in memory and is never returned, logged, or persisted in the model catalog.
|
|
249
|
+
*/
|
|
250
|
+
export async function discoverPiEndpointModels(options = {}) {
|
|
251
|
+
const env = options.env ?? process.env;
|
|
252
|
+
const agentDir = options.agentDir?.trim()
|
|
253
|
+
|| env.PI_CODING_AGENT_DIR?.trim()
|
|
254
|
+
|| path.join(env.HOME?.trim() || homedir(), ".pi", "agent");
|
|
255
|
+
let config;
|
|
256
|
+
try {
|
|
257
|
+
const contents = await readFile(path.join(path.resolve(agentDir), "models.json"), "utf8");
|
|
258
|
+
config = JSON.parse(contents);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
if (!isRecord(config))
|
|
264
|
+
return [];
|
|
265
|
+
const endpoints = providerEndpoints(config.providers, env);
|
|
266
|
+
if (!endpoints.length)
|
|
267
|
+
return [];
|
|
268
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
269
|
+
const lists = await Promise.all(endpoints.map((endpoint) => fetchEndpointModels(endpoint, fetchImpl)));
|
|
270
|
+
const result = [];
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
for (const models of lists) {
|
|
273
|
+
for (const model of models) {
|
|
274
|
+
if (seen.has(model.id))
|
|
275
|
+
continue;
|
|
276
|
+
seen.add(model.id);
|
|
277
|
+
result.push(model);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
@@ -20,12 +20,24 @@ export declare class PtyInputDeliveryError extends Error {
|
|
|
20
20
|
readonly sessionId: string;
|
|
21
21
|
constructor(sessionId: string, cause: unknown);
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Quiet window for the non-Claude PTY turn tracker: a submitted turn stays busy
|
|
25
|
+
* until the CLI has produced no output for this long (see pty-turn-activity.ts).
|
|
26
|
+
* Matches the bridge's own idle-probe delay so both paths make the same call.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PTY_TURN_IDLE_MS = 3000;
|
|
29
|
+
export interface ProcessManagerOptions {
|
|
30
|
+
/** Overrides the non-Claude PTY turn quiet window (tests use a short one). */
|
|
31
|
+
ptyTurnIdleMs?: number;
|
|
32
|
+
}
|
|
23
33
|
export declare class ProcessManager extends EventEmitter {
|
|
24
34
|
private readonly config;
|
|
25
35
|
private readonly storage;
|
|
26
36
|
private readonly sessions;
|
|
27
37
|
private readonly logger;
|
|
28
38
|
private readonly providerHistory;
|
|
39
|
+
/** Quiet window for the non-Claude PTY turn tracker. */
|
|
40
|
+
private readonly ptyTurnIdleMs;
|
|
29
41
|
/** 24h archive scan timer */
|
|
30
42
|
private archiveTimer;
|
|
31
43
|
/** Per-session debounce timers for throttled persist calls */
|
|
@@ -40,10 +52,20 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
40
52
|
private readonly nativeTitles;
|
|
41
53
|
private disposed;
|
|
42
54
|
private readonly terminalHost;
|
|
43
|
-
constructor(config: WandConfig, storage: WandStorage, configDir?: string, terminalHost?: TerminalHost);
|
|
55
|
+
constructor(config: WandConfig, storage: WandStorage, configDir?: string, terminalHost?: TerminalHost, options?: ProcessManagerOptions);
|
|
44
56
|
private makeRestoredRecord;
|
|
45
57
|
private bindRestoredTerminal;
|
|
46
58
|
private initializeClaudeBridge;
|
|
59
|
+
/**
|
|
60
|
+
* Open a non-Claude PTY turn and start its quiet-window timer. Claude's bridge
|
|
61
|
+
* owns ptyBusy, so bridged sessions are left untouched.
|
|
62
|
+
*/
|
|
63
|
+
private openPtyTurn;
|
|
64
|
+
/** Extend the quiet window because the CLI is still producing output. */
|
|
65
|
+
private refreshPtyTurn;
|
|
66
|
+
/** The quiet window elapsed: the CLI is back at its prompt, so the turn is done. */
|
|
67
|
+
private closePtyTurn;
|
|
68
|
+
private clearPtyTurn;
|
|
47
69
|
private bindTerminalProcess;
|
|
48
70
|
private handleTerminalResync;
|
|
49
71
|
private handleTerminalExit;
|
package/dist/process-manager.js
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveSessionProvider, resolveSystemAiContext } from "./session-ai-con
|
|
|
23
23
|
import { resolveSessionCwd } from "./session-cwd.js";
|
|
24
24
|
import { inferProviderFromCommand } from "./session-provider.js";
|
|
25
25
|
import { PtyTerminalState } from "./pty-terminal-state.js";
|
|
26
|
+
import { isPtySubmitInput } from "./pty-turn-activity.js";
|
|
26
27
|
import { buildPtyShellLaunchPlan, PtyCliExitMarker } from "./pty-shell-launch.js";
|
|
27
28
|
import { InProcessTerminalHost, } from "./terminal-host.js";
|
|
28
29
|
import { ProviderHistoryScanner, } from "./provider-history-scanner.js";
|
|
@@ -481,6 +482,12 @@ function restoreTerminalState(state) {
|
|
|
481
482
|
const MAX_SESSIONS = 200;
|
|
482
483
|
const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
|
|
483
484
|
const CONFIRM_WINDOW_SIZE = 800;
|
|
485
|
+
/**
|
|
486
|
+
* Quiet window for the non-Claude PTY turn tracker: a submitted turn stays busy
|
|
487
|
+
* until the CLI has produced no output for this long (see pty-turn-activity.ts).
|
|
488
|
+
* Matches the bridge's own idle-probe delay so both paths make the same call.
|
|
489
|
+
*/
|
|
490
|
+
export const PTY_TURN_IDLE_MS = 3000;
|
|
484
491
|
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
485
492
|
function getClaudeProjectDir(cwd) {
|
|
486
493
|
// Claude Code encodes the project dir by replacing every non-alphanumeric
|
|
@@ -537,6 +544,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
537
544
|
sessions = new Map();
|
|
538
545
|
logger;
|
|
539
546
|
providerHistory = new ProviderHistoryScanner();
|
|
547
|
+
/** Quiet window for the non-Claude PTY turn tracker. */
|
|
548
|
+
ptyTurnIdleMs;
|
|
540
549
|
/** 24h archive scan timer */
|
|
541
550
|
archiveTimer = null;
|
|
542
551
|
/** Per-session debounce timers for throttled persist calls */
|
|
@@ -551,10 +560,11 @@ export class ProcessManager extends EventEmitter {
|
|
|
551
560
|
nativeTitles = new SessionNativeTitleTracker();
|
|
552
561
|
disposed = false;
|
|
553
562
|
terminalHost;
|
|
554
|
-
constructor(config, storage, configDir, terminalHost) {
|
|
563
|
+
constructor(config, storage, configDir, terminalHost, options = {}) {
|
|
555
564
|
super();
|
|
556
565
|
this.config = config;
|
|
557
566
|
this.storage = storage;
|
|
567
|
+
this.ptyTurnIdleMs = options.ptyTurnIdleMs ?? PTY_TURN_IDLE_MS;
|
|
558
568
|
this.terminalHost = terminalHost ?? new InProcessTerminalHost();
|
|
559
569
|
this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
|
|
560
570
|
let startupCodexHistory = null;
|
|
@@ -746,6 +756,48 @@ export class ProcessManager extends EventEmitter {
|
|
|
746
756
|
this.handleBridgeEvent(record, event);
|
|
747
757
|
});
|
|
748
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Open a non-Claude PTY turn and start its quiet-window timer. Claude's bridge
|
|
761
|
+
* owns ptyBusy, so bridged sessions are left untouched.
|
|
762
|
+
*/
|
|
763
|
+
openPtyTurn(record) {
|
|
764
|
+
if (record.ptyBridge || !record.providerCliActive || record.status !== "running")
|
|
765
|
+
return;
|
|
766
|
+
if (record.ptyBusy !== true) {
|
|
767
|
+
record.ptyBusy = true;
|
|
768
|
+
this.emitEvent({ type: "status", sessionId: record.id, data: { ptyBusy: true } });
|
|
769
|
+
}
|
|
770
|
+
this.refreshPtyTurn(record);
|
|
771
|
+
}
|
|
772
|
+
/** Extend the quiet window because the CLI is still producing output. */
|
|
773
|
+
refreshPtyTurn(record) {
|
|
774
|
+
if (record.ptyBridge || !record.providerCliActive)
|
|
775
|
+
return;
|
|
776
|
+
if (record.ptyTurnTimer)
|
|
777
|
+
clearTimeout(record.ptyTurnTimer);
|
|
778
|
+
const timer = setTimeout(() => {
|
|
779
|
+
record.ptyTurnTimer = null;
|
|
780
|
+
if (this.sessions.get(record.id) !== record)
|
|
781
|
+
return;
|
|
782
|
+
this.closePtyTurn(record);
|
|
783
|
+
}, this.ptyTurnIdleMs);
|
|
784
|
+
timer.unref?.();
|
|
785
|
+
record.ptyTurnTimer = timer;
|
|
786
|
+
}
|
|
787
|
+
/** The quiet window elapsed: the CLI is back at its prompt, so the turn is done. */
|
|
788
|
+
closePtyTurn(record) {
|
|
789
|
+
this.clearPtyTurn(record);
|
|
790
|
+
if (record.ptyBusy !== true)
|
|
791
|
+
return;
|
|
792
|
+
record.ptyBusy = false;
|
|
793
|
+
this.emitEvent({ type: "status", sessionId: record.id, data: { ptyBusy: false } });
|
|
794
|
+
}
|
|
795
|
+
clearPtyTurn(record) {
|
|
796
|
+
if (record.ptyTurnTimer) {
|
|
797
|
+
clearTimeout(record.ptyTurnTimer);
|
|
798
|
+
record.ptyTurnTimer = null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
749
801
|
bindTerminalProcess(record, child, onVisibleChunk) {
|
|
750
802
|
child.onResync?.((state) => this.handleTerminalResync(record, child, state));
|
|
751
803
|
child.onData((event) => this.handleTerminalData(record, child, event, onVisibleChunk));
|
|
@@ -817,6 +869,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
817
869
|
current.providerCliActive = false;
|
|
818
870
|
current.providerCliExitCode = exitCode;
|
|
819
871
|
}
|
|
872
|
+
this.clearPtyTurn(current);
|
|
820
873
|
current.ptyBusy = false;
|
|
821
874
|
current.pendingEscalation = null;
|
|
822
875
|
current.ptyPermissionBlocked = false;
|
|
@@ -884,6 +937,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
884
937
|
if (chunk && current.providerCliActive && current.autoApprovePermissions && !current.ptyBridge && current.provider === "claude") {
|
|
885
938
|
this.autoConfirmWithRecord(current, chunk, child);
|
|
886
939
|
}
|
|
940
|
+
// Non-Claude CLIs have no parsed turn boundary; any output while a turn is
|
|
941
|
+
// open keeps it open (see pty-turn-activity.ts).
|
|
942
|
+
if (chunk && current.ptyBusy === true)
|
|
943
|
+
this.refreshPtyTurn(current);
|
|
887
944
|
if (chunk)
|
|
888
945
|
onVisibleChunk?.(chunk);
|
|
889
946
|
if (boundary.exitCode !== null)
|
|
@@ -1213,6 +1270,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
1213
1270
|
}
|
|
1214
1271
|
child.write(initialInputText);
|
|
1215
1272
|
child.write("\r");
|
|
1273
|
+
if (!current.ptyBridge)
|
|
1274
|
+
this.openPtyTurn(current);
|
|
1216
1275
|
};
|
|
1217
1276
|
this.bindTerminalProcess(record, child, (chunk) => {
|
|
1218
1277
|
if (initialInputText && !initialInputSent && (chunk.includes("❯") || chunk.includes("›")))
|
|
@@ -1598,6 +1657,11 @@ export class ProcessManager extends EventEmitter {
|
|
|
1598
1657
|
if (record.ptyBridge && trackUserInput) {
|
|
1599
1658
|
record.ptyBridge.onUserInput(input);
|
|
1600
1659
|
}
|
|
1660
|
+
// Non-Claude CLIs report no parsed boundary, so a line submit opens a turn
|
|
1661
|
+
// that a quiet window later closes.
|
|
1662
|
+
if (trackUserInput && !record.ptyBridge && isPtySubmitInput(input)) {
|
|
1663
|
+
this.openPtyTurn(record);
|
|
1664
|
+
}
|
|
1601
1665
|
}
|
|
1602
1666
|
resize(id, cols, rows) {
|
|
1603
1667
|
const record = this.mustGet(id);
|
|
@@ -1651,6 +1715,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1651
1715
|
record.providerCliExitCode = exitCode;
|
|
1652
1716
|
record.providerShellMarker = null;
|
|
1653
1717
|
record.ptyLaunchMarkerToken = null;
|
|
1718
|
+
this.clearPtyTurn(record);
|
|
1654
1719
|
record.ptyBusy = false;
|
|
1655
1720
|
if (record.claudeTaskDiscoveryTimer) {
|
|
1656
1721
|
clearTimeout(record.claudeTaskDiscoveryTimer);
|
|
@@ -1718,6 +1783,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1718
1783
|
record.providerCliActive = false;
|
|
1719
1784
|
record.providerShellMarker = null;
|
|
1720
1785
|
record.ptyLaunchMarkerToken = null;
|
|
1786
|
+
this.clearPtyTurn(record);
|
|
1721
1787
|
record.ptyBusy = false;
|
|
1722
1788
|
// Kill any running child process (from JSON chat turns)
|
|
1723
1789
|
if (record.childProcess) {
|
|
@@ -1754,6 +1820,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1754
1820
|
return this.snapshot(record);
|
|
1755
1821
|
}
|
|
1756
1822
|
cleanupRecord(record, terminateTerminal = true) {
|
|
1823
|
+
this.clearPtyTurn(record);
|
|
1757
1824
|
if (record.claudeTaskDiscoveryTimer) {
|
|
1758
1825
|
clearTimeout(record.claudeTaskDiscoveryTimer);
|
|
1759
1826
|
record.claudeTaskDiscoveryTimer = null;
|
package/dist/prompt-optimizer.js
CHANGED
|
@@ -31,9 +31,17 @@ export async function optimizePrompt(rawText, language, cwd, ai = {}) {
|
|
|
31
31
|
throw new PromptOptimizeError(`输入过长(${text.length} 字符),请缩短到 ${MAX_INPUT_LENGTH} 以内。`, "INPUT_TOO_LONG");
|
|
32
32
|
}
|
|
33
33
|
const request = buildOptimizePrompt(text, language);
|
|
34
|
-
let raw;
|
|
35
34
|
try {
|
|
36
|
-
|
|
35
|
+
return await callConfiguredAiText(request, cwd ?? process.cwd(), language, ai, (raw) => {
|
|
36
|
+
const cleaned = raw
|
|
37
|
+
.replace(/^```[a-zA-Z]*\n?/, "")
|
|
38
|
+
.replace(/\n?```$/, "")
|
|
39
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
40
|
+
.trim();
|
|
41
|
+
if (!cleaned)
|
|
42
|
+
throw new PromptOptimizeError("AI 返回了空结果。", "EMPTY_RESULT");
|
|
43
|
+
return cleaned;
|
|
44
|
+
});
|
|
37
45
|
}
|
|
38
46
|
catch (error) {
|
|
39
47
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -42,13 +50,4 @@ export async function optimizePrompt(rawText, language, cwd, ai = {}) {
|
|
|
42
50
|
: "AI_OPTIMIZE_FAILED";
|
|
43
51
|
throw new PromptOptimizeError(message, code);
|
|
44
52
|
}
|
|
45
|
-
const cleaned = raw
|
|
46
|
-
.replace(/^```[a-zA-Z]*\n?/, "")
|
|
47
|
-
.replace(/\n?```$/, "")
|
|
48
|
-
.replace(/^["'`]+|["'`]+$/g, "")
|
|
49
|
-
.trim();
|
|
50
|
-
if (!cleaned) {
|
|
51
|
-
throw new PromptOptimizeError("AI 返回了空结果。", "EMPTY_RESULT");
|
|
52
|
-
}
|
|
53
|
-
return cleaned;
|
|
54
53
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY turn-activity heuristics for provider CLIs other than Claude.
|
|
3
|
+
*
|
|
4
|
+
* Claude has `ClaudePtyBridge`, which parses the TUI and reports an exact
|
|
5
|
+
* per-turn phase. Every other provider CLI writes an opaque TUI to the PTY, so
|
|
6
|
+
* the server cannot parse a turn boundary out of the bytes. What is left is
|
|
7
|
+
* output activity: a working CLI keeps redrawing (streamed tokens, spinner,
|
|
8
|
+
* elapsed counters), while a finished turn returns to a static prompt and the
|
|
9
|
+
* bytes stop. So a turn is opened on submit and closed once the session has been
|
|
10
|
+
* silent for a quiet window, refreshed on every chunk.
|
|
11
|
+
*
|
|
12
|
+
* The principled alternative is to ask the OS whether the terminal's foreground
|
|
13
|
+
* process group is blocked on a tty read (that is the real meaning of "waiting
|
|
14
|
+
* for input"). It works for any provider and is immune to spinners, but it needs
|
|
15
|
+
* the PTY-owning daemon (Render) to expose foreground-pgrp state, so it is left
|
|
16
|
+
* as a follow-up. This module is the deliberate fallback.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Whether a PTY input chunk is a line submit rather than body text.
|
|
20
|
+
*
|
|
21
|
+
* The input contract sends the prompt text and the terminating Enter as two
|
|
22
|
+
* separate chunks (`getTerminalSubmitChunks`), so a submit is a chunk made
|
|
23
|
+
* purely of line terminators (plus whitespace). Pasted text that happens to end
|
|
24
|
+
* in a newline carries body and is intentionally not treated as a submit: a TUI
|
|
25
|
+
* may still be editing it, and a missed turn start only under-reports activity.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isPtySubmitInput(input: string): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY turn-activity heuristics for provider CLIs other than Claude.
|
|
3
|
+
*
|
|
4
|
+
* Claude has `ClaudePtyBridge`, which parses the TUI and reports an exact
|
|
5
|
+
* per-turn phase. Every other provider CLI writes an opaque TUI to the PTY, so
|
|
6
|
+
* the server cannot parse a turn boundary out of the bytes. What is left is
|
|
7
|
+
* output activity: a working CLI keeps redrawing (streamed tokens, spinner,
|
|
8
|
+
* elapsed counters), while a finished turn returns to a static prompt and the
|
|
9
|
+
* bytes stop. So a turn is opened on submit and closed once the session has been
|
|
10
|
+
* silent for a quiet window, refreshed on every chunk.
|
|
11
|
+
*
|
|
12
|
+
* The principled alternative is to ask the OS whether the terminal's foreground
|
|
13
|
+
* process group is blocked on a tty read (that is the real meaning of "waiting
|
|
14
|
+
* for input"). It works for any provider and is immune to spinners, but it needs
|
|
15
|
+
* the PTY-owning daemon (Render) to expose foreground-pgrp state, so it is left
|
|
16
|
+
* as a follow-up. This module is the deliberate fallback.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Whether a PTY input chunk is a line submit rather than body text.
|
|
20
|
+
*
|
|
21
|
+
* The input contract sends the prompt text and the terminating Enter as two
|
|
22
|
+
* separate chunks (`getTerminalSubmitChunks`), so a submit is a chunk made
|
|
23
|
+
* purely of line terminators (plus whitespace). Pasted text that happens to end
|
|
24
|
+
* in a newline carries body and is intentionally not treated as a submit: a TUI
|
|
25
|
+
* may still be editing it, and a missed turn start only under-reports activity.
|
|
26
|
+
*/
|
|
27
|
+
export function isPtySubmitInput(input) {
|
|
28
|
+
if (!input || !/[\r\n]/.test(input))
|
|
29
|
+
return false;
|
|
30
|
+
return input.replace(/[\r\n]/g, "").trim().length === 0;
|
|
31
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { AI_TEAM_MAX_CANDIDATES, SILICON_EMPLOYEE_AVATAR_MAX_CHARS, agentKey, } from "./ai-team-types.js";
|
|
2
|
+
import { AI_TEAM_MAX_CANDIDATES, SILICON_EMPLOYEE_AVATAR_MAX_CHARS, agentKey, isBuiltinSiliconEmployee, } from "./ai-team-types.js";
|
|
3
3
|
import { getErrorMessage } from "./error-utils.js";
|
|
4
4
|
import { asyncRoute } from "./express-async.js";
|
|
5
5
|
import { bodyObject, sendRouteError, text } from "./server-request.js";
|
|
6
6
|
import { parseTaskAgent } from "./server-task-routes.js";
|
|
7
7
|
import { resolveSystemAiContext } from "./session-ai-context.js";
|
|
8
8
|
import { generateSiliconEmployeeDraft } from "./silicon-employee-draft.js";
|
|
9
|
-
import {
|
|
9
|
+
import { EMPLOYEE_KNOWLEDGE_MAX_ENTRIES } from "./employee-knowledge-types.js";
|
|
10
10
|
/** 员工起草没有会话上下文,按「默认 provider」解析,与任务标题 / 提示词优化一致。 */
|
|
11
11
|
function employeeDraftAiOptions(storage, config) {
|
|
12
12
|
if (!config)
|
|
@@ -28,9 +28,9 @@ function sendEmployeeError(res, error) {
|
|
|
28
28
|
}
|
|
29
29
|
/** 内置员工是 Wand 自己的执行者,不能被归档或删除。 */
|
|
30
30
|
function rejectSystemEmployeeMutation(existing, action) {
|
|
31
|
-
if (!
|
|
31
|
+
if (!isBuiltinSiliconEmployee(existing))
|
|
32
32
|
return;
|
|
33
|
-
throw new Error(`「${existing.name}」是 Wand
|
|
33
|
+
throw new Error(`「${existing.name}」是 Wand 内置员工,不能${action}。`);
|
|
34
34
|
}
|
|
35
35
|
function boundedText(value, label, min, max) {
|
|
36
36
|
const result = text(value);
|
|
@@ -72,7 +72,7 @@ export function parseSystemEmployeeAgents(value, existing) {
|
|
|
72
72
|
const current = existing[field.key];
|
|
73
73
|
const isSame = typeof incoming === "string" && incoming === current;
|
|
74
74
|
if (!isSame) {
|
|
75
|
-
throw new Error(
|
|
75
|
+
throw new Error(`该员工是内置的,${field.label}不可修改。`);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
if (!Array.isArray(body.agents))
|
|
@@ -214,13 +214,59 @@ export function registerSiliconEmployeeRoutes(app, deps) {
|
|
|
214
214
|
sendEmployeeError(res, error);
|
|
215
215
|
}
|
|
216
216
|
});
|
|
217
|
+
app.get("/api/silicon-employees/:id/knowledge", (req, res) => {
|
|
218
|
+
try {
|
|
219
|
+
res.setHeader("Cache-Control", "no-store");
|
|
220
|
+
const employeeId = req.params.id;
|
|
221
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
222
|
+
res.json({ employeeId, entries: storage.listEmployeeKnowledge(employeeId, query),
|
|
223
|
+
total: storage.countEmployeeKnowledge(employeeId), maxEntries: EMPLOYEE_KNOWLEDGE_MAX_ENTRIES });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
sendEmployeeError(res, error);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
app.post("/api/silicon-employees/:id/knowledge", (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const body = bodyObject(req.body);
|
|
232
|
+
if (typeof body.content !== "string")
|
|
233
|
+
throw new Error("content 必须是知识文字。");
|
|
234
|
+
const entry = storage.rememberEmployeeKnowledge(req.params.id, body.content);
|
|
235
|
+
res.status(201).json(entry);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
sendEmployeeError(res, error);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
app.delete("/api/silicon-employees/:id/knowledge/:entryId", (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
const deleted = storage.forgetEmployeeKnowledge(req.params.id, req.params.entryId);
|
|
244
|
+
if (!deleted) {
|
|
245
|
+
res.status(404).json({ error: "该知识不属于当前员工或已删除。" });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
res.json({ deleted: true });
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
sendEmployeeError(res, error);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
app.delete("/api/silicon-employees/:id/knowledge", (req, res) => {
|
|
255
|
+
try {
|
|
256
|
+
storage.clearEmployeeKnowledge(req.params.id);
|
|
257
|
+
res.json({ employeeId: req.params.id, entries: [], total: 0, maxEntries: EMPLOYEE_KNOWLEDGE_MAX_ENTRIES });
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
sendEmployeeError(res, error);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
217
263
|
app.put("/api/silicon-employees/:id", (req, res) => {
|
|
218
264
|
try {
|
|
219
265
|
const existing = storage.getSiliconEmployee(req.params.id);
|
|
220
266
|
if (!existing)
|
|
221
267
|
throw new Error(`硅基员工「${req.params.id}」不存在。`);
|
|
222
268
|
const now = new Date().toISOString();
|
|
223
|
-
const employee =
|
|
269
|
+
const employee = isBuiltinSiliconEmployee(existing)
|
|
224
270
|
? { ...existing, agents: parseSystemEmployeeAgents(req.body, existing), updatedAt: now }
|
|
225
271
|
: parseSiliconEmployeeInput(req.body, existing, now);
|
|
226
272
|
storage.saveSiliconEmployee(employee);
|