@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.
Files changed (64) hide show
  1. package/dist/agent-dispatch.js +7 -4
  2. package/dist/ai-team-prompts.d.ts +1 -1
  3. package/dist/ai-team-prompts.js +2 -2
  4. package/dist/ai-team-runner.d.ts +2 -0
  5. package/dist/ai-team-runner.js +60 -7
  6. package/dist/ai-team-types.d.ts +6 -0
  7. package/dist/ai-team-types.js +11 -0
  8. package/dist/build-info.json +3 -3
  9. package/dist/cli.js +11 -0
  10. package/dist/config.js +2 -0
  11. package/dist/default-employee.d.ts +11 -0
  12. package/dist/default-employee.js +33 -0
  13. package/dist/employee-knowledge-cli.d.ts +2 -0
  14. package/dist/employee-knowledge-cli.js +52 -0
  15. package/dist/employee-knowledge-content.d.ts +4 -0
  16. package/dist/employee-knowledge-content.js +23 -0
  17. package/dist/employee-knowledge-types.d.ts +15 -0
  18. package/dist/employee-knowledge-types.js +3 -0
  19. package/dist/employee-knowledge.d.ts +6 -0
  20. package/dist/employee-knowledge.js +84 -0
  21. package/dist/git-quick-commit.d.ts +2 -0
  22. package/dist/git-quick-commit.js +121 -58
  23. package/dist/iteration-log.d.ts +5 -0
  24. package/dist/iteration-log.js +12 -1
  25. package/dist/message-truncator.js +13 -9
  26. package/dist/models.d.ts +4 -0
  27. package/dist/models.js +38 -10
  28. package/dist/pi-model-discovery.d.ts +12 -0
  29. package/dist/pi-model-discovery.js +281 -0
  30. package/dist/process-manager.d.ts +23 -1
  31. package/dist/process-manager.js +68 -1
  32. package/dist/prompt-optimizer.js +10 -11
  33. package/dist/pty-turn-activity.d.ts +27 -0
  34. package/dist/pty-turn-activity.js +31 -0
  35. package/dist/server-employee-routes.js +52 -6
  36. package/dist/server-session-routes.js +11 -5
  37. package/dist/server-task-routes.js +6 -1
  38. package/dist/server-user-memory.d.ts +9 -0
  39. package/dist/server-user-memory.js +96 -0
  40. package/dist/server.js +22 -2
  41. package/dist/session-topic.js +6 -5
  42. package/dist/silicon-employee-draft.js +1 -3
  43. package/dist/storage.d.ts +38 -0
  44. package/dist/storage.js +276 -3
  45. package/dist/structured-claude-adapter.d.ts +1 -1
  46. package/dist/structured-claude-adapter.js +2 -0
  47. package/dist/structured-provider-common.d.ts +2 -2
  48. package/dist/structured-provider-common.js +6 -6
  49. package/dist/structured-session-manager.js +5 -4
  50. package/dist/task-title.js +8 -7
  51. package/dist/team-report-file-link.d.ts +6 -0
  52. package/dist/team-report-file-link.js +54 -0
  53. package/dist/team-report-preview.d.ts +3 -0
  54. package/dist/team-report-preview.js +70 -0
  55. package/dist/types.d.ts +15 -0
  56. package/dist/user-memory-types.d.ts +42 -0
  57. package/dist/user-memory-types.js +6 -0
  58. package/dist/user-memory.d.ts +30 -0
  59. package/dist/user-memory.js +190 -0
  60. package/dist/web-ui/content/ai-teams.js +13 -13
  61. package/dist/web-ui/content/scripts.js +59 -61
  62. package/dist/web-ui/content/styles.css +1 -1
  63. package/dist/web-ui/embedded-assets.js +3 -3
  64. 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;
@@ -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;
@@ -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
- raw = await callConfiguredAiText(request, cwd ?? process.cwd(), language, ai);
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 { isSystemSiliconEmployee } from "./system-employee.js";
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 (!isSystemSiliconEmployee(existing))
31
+ if (!isBuiltinSiliconEmployee(existing))
32
32
  return;
33
- throw new Error(`「${existing.name}」是 Wand 内置的系统运维员工,不能${action}。`);
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(`系统运维员工是内置的,${field.label}不可修改。`);
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 = isSystemSiliconEmployee(existing)
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);