@hunterzhu/pulse-cli 0.5.0 → 0.6.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/bin.js CHANGED
@@ -3,7 +3,8 @@ import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { assertJarvisApiUrl, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
6
+ import { createLocalHost, automationDoctor, requestAutomationPermissions, assertJarvisApiUrl, createBuiltinSkillCapabilityPack, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
7
+ import { modelSelectionId, normalizeModelConfig, resolveModelReference } from '@hunterzhu/pulse-server';
7
8
  import { defaultPulseConfig, defaultPulseConfigPath, ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
8
9
  import { runInteractive } from './commands/interactive.js';
9
10
  import { runOneShot } from './commands/run.js';
@@ -32,13 +33,14 @@ Usage:
32
33
  pulse run <task> [options] run one task
33
34
  pulse sessions [options] list saved conversations
34
35
  pulse resume <conversation-id> [task] continue or recover a conversation
35
- pulse doctor [options] check local configuration
36
+ pulse drafts <conversation-id> <run-id> <draft-id> [--revision <hash> --confirm-owner]\n preview or explicitly migrate an ownerless draft\n pulse doctor [options] check local configuration
36
37
  pulse h5 [start|qr|devices|revoke|install|status|stop|uninstall]
37
38
  mobile web assistant and launchd daemon
38
39
  pulse schedule <add|list|pause|resume|remove|daemon> [options]
39
40
  manage recurring tasks (daemon requires read-only or auto approval)
40
41
  pulse service <install|status|start|stop|uninstall>
41
42
  pulse template <list|show|run> [name] [values...]
43
+ pulse automation doctor check browser and desktop dependencies
42
44
  pulse mcp doctor [server-id]
43
45
 
44
46
  Options:
@@ -50,8 +52,9 @@ Options:
50
52
  --max-output-tokens <n> maximum generated tokens (default 4096)
51
53
  --reasoning-effort <x> low, medium, or high
52
54
  --max-turns <n> maximum ReAct model/tool turns (default 32)
55
+ --max-model-calls <n> maximum model attempts per work task (default 256)
53
56
  --auto-compact-percent <n> compact automatically at this percent of the context window (1-90, default 90)
54
- --format <text|jsonl> output format
57
+ --accept-build-migration <old:new> authorize one reviewed recovery build pair\n --requirements-doc <path> explicitly attach a workspace requirements document\n --format <text|jsonl> output format
55
58
  --resume enter the latest saved session
56
59
  --read-only disable write and shell tools
57
60
  --auto-approve approve local writes and shell execution on your behalf
@@ -95,6 +98,7 @@ const knownBooleanFlags = new Set([
95
98
  'help',
96
99
  'version',
97
100
  'force',
101
+ 'confirm-owner',
98
102
  'jarvis',
99
103
  'no-jarvis',
100
104
  'jarvis-allow-remote',
@@ -141,7 +145,7 @@ export function parse(argv) {
141
145
  options[key] = true;
142
146
  continue;
143
147
  }
144
- if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup', 'schedule', 'service', 'template', 'mcp', 'h5'].includes(arg)) {
148
+ if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup', 'schedule', 'service', 'template', 'mcp', 'h5', 'automation', 'drafts'].includes(arg)) {
145
149
  command = arg;
146
150
  }
147
151
  else {
@@ -162,7 +166,7 @@ export async function hostOptions(parsed) {
162
166
  }
163
167
  const loaded = (await loadPulseConfig(requestedCwd ?? process.cwd(), explicitConfig, parsed.options['trust-workspace'] === true)).value;
164
168
  // A missing user config still has to answer usage errors and list sessions.
165
- const config = loaded.activeModel || loaded.models || loaded.providers ? loaded : { ...defaultPulseConfig, ...loaded };
169
+ const config = normalizeModelConfig(loaded.activeModel || loaded.models || loaded.providers ? loaded : { ...defaultPulseConfig, ...loaded });
166
170
  const mockResponse = option(parsed.options, 'mock-response');
167
171
  const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
168
172
  const mockStageAssessment = option(parsed.options, 'mock-stage-assessment');
@@ -171,25 +175,33 @@ export async function hostOptions(parsed) {
171
175
  const mockParallelPlanOption = option(parsed.options, 'mock-parallel-plan');
172
176
  const hasMockDemand = mockResponse !== undefined || mockTaskAssessment !== undefined || mockStageAssessment !== undefined || mockToolCallsOption !== undefined || mockAfterToolResponse !== undefined || mockParallelPlanOption !== undefined;
173
177
  const cliModel = option(parsed.options, 'model');
174
- const requestedModel = cliModel ?? (hasMockDemand ? 'mock' : process.env.PULSE_MODEL ?? config.activeModel);
175
- const effectiveModels = { ...(config.models ?? {}) };
178
+ const requestedModel = cliModel ?? (hasMockDemand ? 'mock/mock' : process.env.PULSE_MODEL ?? config.activeModel);
176
179
  const effectiveProviders = { ...(config.providers ?? {}) };
177
- if (requestedModel === 'mock' || (hasMockDemand && cliModel === undefined)) {
178
- if (!effectiveModels.mock) {
179
- effectiveModels.mock = { displayName: 'mock', provider: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' };
180
- }
180
+ if (requestedModel === 'mock/mock' || requestedModel === 'mock' || (hasMockDemand && cliModel === undefined)) {
181
181
  if (!effectiveProviders.mock) {
182
182
  effectiveProviders.mock = { provider: 'mock', name: 'Mock' };
183
183
  }
184
+ effectiveProviders.mock.models ??= {};
185
+ effectiveProviders.mock.models.mock ??= { displayName: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' };
186
+ }
187
+ const catalog = { ...config, providers: effectiveProviders };
188
+ const selections = Object.entries(effectiveProviders).flatMap(([providerId, profile]) => Object.entries(profile.models ?? {}).map(([modelId, model]) => ({ id: modelSelectionId(providerId, modelId), providerId, modelId, model })));
189
+ if (!requestedModel)
190
+ throw new Error('UNKNOWN_MODEL_DISPLAY_NAME:(missing)');
191
+ let activeModelName;
192
+ try {
193
+ activeModelName = resolveModelReference(catalog, requestedModel);
194
+ }
195
+ catch (error) {
196
+ if (error instanceof Error && error.message.startsWith('AMBIGUOUS_MODEL_REFERENCE:'))
197
+ throw error;
198
+ throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel}`);
184
199
  }
185
- const configuredModelEntry = requestedModel === undefined
186
- ? undefined
187
- : Object.entries(effectiveModels).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
200
+ const configuredModelEntry = selections.find(item => item.id === activeModelName);
188
201
  if (!configuredModelEntry)
189
- throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel ?? '(missing)'}`);
190
- const [, modelSelection] = configuredModelEntry;
191
- const activeModelName = modelSelection.displayName;
192
- const providerName = modelSelection.provider;
202
+ throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel}`);
203
+ const modelSelection = configuredModelEntry.model;
204
+ const providerName = configuredModelEntry.providerId;
193
205
  const profile = effectiveProviders[providerName];
194
206
  if (!profile)
195
207
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${providerName}`);
@@ -201,6 +213,7 @@ export async function hostOptions(parsed) {
201
213
  const maxOutputTokens = option(parsed.options, 'max-output-tokens') ?? process.env.PULSE_MAX_OUTPUT_TOKENS;
202
214
  const reasoningEffort = option(parsed.options, 'reasoning-effort') ?? process.env.PULSE_REASONING_EFFORT;
203
215
  const maxTurns = option(parsed.options, 'max-turns') ?? process.env.PULSE_MAX_TURNS;
216
+ const maxModelCalls = option(parsed.options, 'max-model-calls') ?? process.env.PULSE_MAX_MODEL_CALLS;
204
217
  const maxRuntimeMs = option(parsed.options, 'max-runtime-ms') ?? process.env.PULSE_MAX_RUNTIME_MS;
205
218
  const autoCompactPercent = option(parsed.options, 'auto-compact-percent') ?? process.env.PULSE_AUTO_COMPACT_PERCENT;
206
219
  const apiKeyEnv = profile.apiKeyEnv ?? (profile.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
@@ -216,6 +229,7 @@ export async function hostOptions(parsed) {
216
229
  const configuredReasoningEffort = reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high' ? reasoningEffort : modelSelection.reasoningEffort ?? profile.reasoningEffort;
217
230
  const configuredToolChoice = profile.toolChoice;
218
231
  const configuredMaxTurns = parsePositiveInteger(maxTurns) ?? config.maxTurns;
232
+ const configuredMaxModelCalls = parsePositiveInteger(maxModelCalls) ?? config.maxModelCalls ?? 256;
219
233
  const configuredMaxRuntimeMs = parsePositiveInteger(maxRuntimeMs) ?? config.maxRuntimeMs;
220
234
  const configuredAutoCompactPercent = parsePositiveInteger(autoCompactPercent) ?? config.autoCompactPercent;
221
235
  const provider = {
@@ -244,28 +258,30 @@ export async function hostOptions(parsed) {
244
258
  ...(item.toolChoice === undefined ? {} : { toolChoice: item.toolChoice }),
245
259
  }]];
246
260
  }));
247
- const modelDisplayNames = new Set();
248
- const providerModels = Object.fromEntries(Object.entries(effectiveModels).flatMap(([name, item]) => {
249
- if (!effectiveProviders[item.provider])
250
- throw new Error(`MODEL_PROVIDER_NOT_FOUND:${item.provider}`);
251
- const displayName = item.displayName.trim();
252
- if (!displayName)
253
- throw new Error(`MODEL_DISPLAY_NAME_REQUIRED:${name}`);
254
- if (modelDisplayNames.has(displayName))
255
- throw new Error(`DUPLICATE_MODEL_DISPLAY_NAME:${displayName}`);
256
- modelDisplayNames.add(displayName);
257
- return [[displayName, {
258
- provider: item.provider,
259
- model: item.modelCode,
260
- ...(item.maxContextTokens === undefined ? {} : { maxContextTokens: item.maxContextTokens }),
261
- ...(item.maxOutputTokens === undefined ? {} : { maxOutputTokens: item.maxOutputTokens }),
262
- ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort }),
263
- }]];
264
- }));
265
- const assistantModelName = config.assistantModel ?? activeModelName;
261
+ for (const selection of selections)
262
+ if (selection.model.imageInput !== undefined && !['supported', 'unsupported', 'unknown'].includes(selection.model.imageInput))
263
+ throw new Error(`INVALID_MODEL_IMAGE_INPUT:${selection.id}`);
264
+ const providerModels = Object.fromEntries(selections.map(({ id, providerId, model }) => [id, {
265
+ provider: providerId,
266
+ model: model.modelCode,
267
+ ...(model.imageInput === undefined ? {} : { imageInput: model.imageInput }),
268
+ ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
269
+ ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
270
+ ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
271
+ }]));
272
+ // Explicit CLI/env limits must reach the routed candidate too. Otherwise the
273
+ // model catalog silently overrides the adapter's smaller output/effort limits.
274
+ const activeCandidate = providerModels[activeModelName];
275
+ if (parsePositiveInteger(contextTokens) !== undefined)
276
+ activeCandidate.maxContextTokens = configuredContextTokens;
277
+ if (parsePositiveInteger(maxOutputTokens) !== undefined)
278
+ activeCandidate.maxOutputTokens = configuredMaxOutputTokens;
279
+ if (reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high')
280
+ activeCandidate.reasoningEffort = reasoningEffort;
281
+ const assistantModelName = config.assistantModel ? resolveModelReference(catalog, config.assistantModel) : activeModelName;
266
282
  if (!providerModels[assistantModelName])
267
283
  throw new Error(`UNKNOWN_ASSISTANT_MODEL:${assistantModelName}`);
268
- const taskRouting = config.taskRouting;
284
+ const taskRouting = config.taskRouting && Object.fromEntries(Object.entries(config.taskRouting).map(([task, route]) => [task, Array.isArray(route) ? route.map(item => resolveModelReference(catalog, item)) : route]));
269
285
  for (const [task, models] of Object.entries(taskRouting ?? {})) {
270
286
  if (!Array.isArray(models) || models.length === 0 || models.some((model) => typeof model !== 'string' || !providerModels[model])) {
271
287
  throw new Error(`INVALID_TASK_MODEL_ROUTE:${task}`);
@@ -337,14 +353,18 @@ export async function hostOptions(parsed) {
337
353
  const skillRoots = capabilitySettings.trustedSkillRoots ?? [];
338
354
  if (!Array.isArray(skillRoots) || skillRoots.some((root) => typeof root !== 'string' || !isAbsolute(expandHome(root) ?? root)))
339
355
  throw new Error('SKILL_TRUSTED_ROOT_MUST_BE_ABSOLUTE');
340
- const enabledCapabilityPacks = capabilitySettings.enabled ?? [];
341
- if (!Array.isArray(enabledCapabilityPacks) || enabledCapabilityPacks.some((id) => typeof id !== 'string') || new Set(enabledCapabilityPacks).size !== enabledCapabilityPacks.length)
356
+ const configuredCapabilityPacks = capabilitySettings.enabled ?? [];
357
+ if (!Array.isArray(configuredCapabilityPacks) || configuredCapabilityPacks.some((id) => typeof id !== 'string') || new Set(configuredCapabilityPacks).size !== configuredCapabilityPacks.length)
342
358
  throw new Error('INVALID_ENABLED_CAPABILITY_LIST');
359
+ // Built-in skills are always enabled so they are discoverable with no external skills directory.
360
+ const enabledCapabilityPacks = ['builtin-skills', ...configuredCapabilityPacks.filter((id) => id !== 'builtin-skills')];
343
361
  const skillNames = capabilitySettings.skills ?? [];
344
362
  if (!Array.isArray(skillNames) || skillNames.some((name) => typeof name !== 'string'))
345
363
  throw new Error('INVALID_SKILL_SELECTION');
346
- const capabilityPacks = [createPdfCapabilityPack(), createSpreadsheetCapabilityPack(), createSkillCapabilityPack({ trustedRoots: skillRoots.map((root) => resolve(expandHome(root) ?? root)) })];
364
+ const capabilityPacks = [createBuiltinSkillCapabilityPack(), createPdfCapabilityPack(), createSpreadsheetCapabilityPack(), createSkillCapabilityPack({ trustedRoots: skillRoots.map((root) => resolve(expandHome(root) ?? root)) })];
347
365
  for (const [id, server] of Object.entries(capabilitySettings.mcpServers ?? {})) {
366
+ if ((id === 'browser' && capabilitySettings.browser) || (id === 'computer' && capabilitySettings.computer))
367
+ throw new Error(`AUTOMATION_CAPABILITY_CONFLICT:${id}`);
348
368
  if (!server || typeof server.command !== 'string' || server.command.trim().length === 0 || (server.args !== undefined && (!Array.isArray(server.args) || server.args.some((arg) => typeof arg !== 'string'))))
349
369
  throw new Error(`INVALID_MCP_SERVER_CONFIG:${id}`);
350
370
  if (server.toolPolicies && Object.entries(server.toolPolicies).some(([name, policy]) => !name || !['read', 'write', 'external'].includes(policy)))
@@ -368,7 +388,7 @@ export async function hostOptions(parsed) {
368
388
  }));
369
389
  }
370
390
  for (const id of enabledCapabilityPacks) {
371
- if (!capabilityPacks.some((pack) => pack.manifest.id === id))
391
+ if (!['browser', 'computer'].includes(id) && !capabilityPacks.some((pack) => pack.manifest.id === id))
372
392
  throw new Error(`UNKNOWN_ENABLED_CAPABILITY:${id}`);
373
393
  }
374
394
  const rawSystemPrompt = option(parsed.options, 'system-prompt') ?? process.env.PULSE_SYSTEM_PROMPT ?? config.systemPrompt;
@@ -425,7 +445,8 @@ export async function hostOptions(parsed) {
425
445
  provider,
426
446
  providerProfiles,
427
447
  providerModels,
428
- modelPricing: Object.fromEntries(Object.values(config.models ?? {}).flatMap((item) => item.pricing ? [[item.displayName, item.pricing]] : [])),
448
+ automation: { ...(capabilitySettings.browser ? { browser: capabilitySettings.browser } : {}), ...(capabilitySettings.computer ? { computer: capabilitySettings.computer } : {}) },
449
+ modelPricing: Object.fromEntries(selections.flatMap(({ id, model }) => model.pricing ? [[id, model.pricing]] : [])),
429
450
  ...(taskRouting === undefined ? {} : { taskRouting }),
430
451
  capabilityPacks,
431
452
  enabledCapabilityPacks,
@@ -441,13 +462,32 @@ export async function hostOptions(parsed) {
441
462
  ...(mockParallelPlan === undefined ? {} : { mockParallelPlan }),
442
463
  ...(approvalMode === undefined ? {} : { approvalMode }),
443
464
  ...(configuredMaxTurns === undefined ? {} : { maxTurns: configuredMaxTurns }),
465
+ maxModelCalls: configuredMaxModelCalls,
466
+ ...(option(parsed.options, 'accept-build-migration') ? { recoveryBuildMigration: (() => { const pair = option(parsed.options, 'accept-build-migration').split(':'); if (pair.length !== 2 || pair.some(hash => !/^[a-f0-9]{64}$/.test(hash)))
467
+ throw new Error('Invalid build migration pair; use the two hashes in the recovery diagnostic.'); return { from: pair[0], to: pair[1] }; })() } : {}),
468
+ ...(config.modelBudgetPolicy === undefined ? {} : { modelBudgetPolicy: config.modelBudgetPolicy }),
469
+ ...(config.persistenceBackend === undefined ? {} : { persistenceBackend: config.persistenceBackend }),
470
+ ...(config.maxCostByCurrency === undefined ? {} : { maxCostByCurrency: config.maxCostByCurrency }),
444
471
  ...(configuredMaxRuntimeMs === undefined ? {} : { maxRuntimeMs: configuredMaxRuntimeMs }),
445
472
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
446
473
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
447
474
  ...(config.networkHosts === undefined ? {} : { networkHosts: config.networkHosts }),
448
475
  jarvis,
449
- ...(config.frontModel ? { frontModel: config.frontModel } : {}),
450
- ...(config.approvalReview?.model ? { approvalReview: { provider: config.approvalReview.provider ?? 'ollama', model: config.approvalReview.model, baseURL: config.approvalReview.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(1_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 15_000)) } } : {}),
476
+ ...(config.frontModel ? { frontModel: (() => {
477
+ const selected = typeof config.frontModel?.model === 'string' ? resolveModelReference(catalog, config.frontModel.model) : undefined;
478
+ const entry = selections.find(item => item.id === selected);
479
+ const source = entry && effectiveProviders[entry.providerId];
480
+ const keyEnv = source?.apiKeyEnv ?? (source?.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
481
+ return { ...config.frontModel, ...(entry && source ? { provider: source.provider, model: entry.model.modelCode, ...(source.baseURL ? { baseURL: source.baseURL } : {}), ...(process.env[keyEnv] ? { apiKey: process.env[keyEnv] } : {}) } : {}) };
482
+ })() } : {}),
483
+ ...(config.approvalReview?.model ? { approvalReview: (() => {
484
+ const selected = resolveModelReference(catalog, config.approvalReview.model);
485
+ const entry = selections.find(item => item.id === selected);
486
+ const source = entry && effectiveProviders[entry.providerId];
487
+ if (!entry || !source || source.provider !== 'ollama')
488
+ throw new Error('APPROVAL_REVIEW_REQUIRES_OLLAMA');
489
+ return { provider: 'ollama', model: entry.model.modelCode, baseURL: source.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(30_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 30_000)) };
490
+ })() } : {}),
451
491
  };
452
492
  }
453
493
  export async function main() {
@@ -466,9 +506,43 @@ export async function main() {
466
506
  return runSetup(parsed.options.force === true, expandHome(option(parsed.options, 'config')));
467
507
  }
468
508
  const options = await hostOptions(parsed);
509
+ if (parsed.command === 'automation') {
510
+ if (parsed.positionals[0] === 'permissions') {
511
+ process.stdout.write(JSON.stringify(await requestAutomationPermissions(options.automation), null, 2) + '\n');
512
+ return 0;
513
+ }
514
+ if (parsed.positionals[0] !== 'doctor')
515
+ throw new Error('Usage: pulse automation doctor|permissions');
516
+ if (parsed.options.live === true) {
517
+ const host = createLocalHost(options);
518
+ try {
519
+ process.stdout.write(JSON.stringify(await host.probeVisionModel(option(parsed.options, 'model') ?? options.taskRouting?.vision?.[0] ?? options.activeModel ?? ''), null, 2) + '\n');
520
+ return 0;
521
+ }
522
+ finally {
523
+ await host.close();
524
+ }
525
+ }
526
+ const report = await automationDoctor(options.automation);
527
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
528
+ return report.desktop.error && options.enabledCapabilityPacks?.includes('computer') ? 1 : 0;
529
+ }
469
530
  if (parsed.command === 'doctor') {
470
531
  return runDoctor(options, parsed.options.live === true, option(parsed.options, 'format') ?? 'text');
471
532
  }
533
+ if (parsed.command === 'drafts') {
534
+ const [conversationId, runId, draftId] = parsed.positionals;
535
+ if (!conversationId || !runId || !draftId)
536
+ throw new Error('Usage: pulse drafts <conversation-id> <run-id> <draft-id> [--revision <hash> --confirm-owner]');
537
+ const host = createLocalHost(options);
538
+ try {
539
+ process.stdout.write(JSON.stringify(await host.migrateDraft(conversationId, runId, draftId, option(parsed.options, 'revision'), parsed.options['confirm-owner'] === true), null, 2) + '\n');
540
+ return 0;
541
+ }
542
+ finally {
543
+ await host.close();
544
+ }
545
+ }
472
546
  if (parsed.command === 'sessions') {
473
547
  return runSessions(options, option(parsed.options, 'format') ?? 'text', version);
474
548
  }
@@ -525,7 +599,7 @@ export async function main() {
525
599
  }
526
600
  if (!task)
527
601
  throw new Error('TASK_REQUIRED');
528
- return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version);
602
+ return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version, option(parsed.options, 'requirements-doc') ? [option(parsed.options, 'requirements-doc')] : undefined);
529
603
  }
530
604
  if (parsed.command === 'resume') {
531
605
  const id = parsed.positionals.shift();
@@ -1,3 +1,3 @@
1
1
  import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
- export declare function findResumeConversation(options: LocalHostOptions): Promise<string | undefined>;
2
+ export declare function findResumeConversation(options: LocalHostOptions, conversationId?: string | undefined): Promise<string | undefined>;
3
3
  export declare function runInteractive(options: LocalHostOptions, conversationId?: string | undefined, initialTask?: string | undefined, version?: string, resumeLatest?: boolean): Promise<number>;
@@ -4,10 +4,24 @@ import { App } from '../components/App.js';
4
4
  import { createLocalHost } from '@hunterzhu/pulse-server';
5
5
  import { closePendingHosts } from '../hooks/useHost.js';
6
6
  import { runOneShot } from './run.js';
7
- export async function findResumeConversation(options) {
7
+ export async function findResumeConversation(options, conversationId) {
8
8
  const host = createLocalHost(options);
9
9
  await host.init();
10
10
  try {
11
+ if (conversationId !== undefined) {
12
+ // Validate an explicit ID without creating a conversation. A missing ID
13
+ // must fail loudly instead of silently opening a fresh history.
14
+ try {
15
+ await host.getConversation(conversationId);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT') {
19
+ throw Object.assign(new Error(`未找到指定会话:${conversationId}`), { code: 'CONVERSATION_NOT_FOUND' });
20
+ }
21
+ throw error;
22
+ }
23
+ return conversationId;
24
+ }
11
25
  const sessions = await host.listConversations();
12
26
  return sessions.find((session) => session.activeRunId)?.id ?? sessions[0]?.id;
13
27
  }
@@ -23,10 +37,11 @@ export async function runInteractive(options, conversationId, initialTask, versi
23
37
  process.stderr.write('交互模式需要 TTY 终端。非交互执行请使用 "pulse run <task>"。\n');
24
38
  return 1;
25
39
  }
26
- const selectedConversationId = resumeLatest
27
- ? await findResumeConversation(options)
28
- : conversationId;
29
- const { waitUntilExit, unmount } = render(_jsx(App, { hostOptions: options, conversationId: selectedConversationId, initialTask: initialTask, resumeOnStart: resumeLatest && selectedConversationId !== undefined, version: version }), {
40
+ const shouldResume = resumeLatest || conversationId !== undefined;
41
+ const selectedConversationId = shouldResume
42
+ ? await findResumeConversation(options, conversationId)
43
+ : undefined;
44
+ const { waitUntilExit, unmount } = render(_jsx(App, { hostOptions: options, conversationId: selectedConversationId, initialTask: initialTask, resumeOnStart: shouldResume && selectedConversationId !== undefined, version: version }), {
30
45
  // Keep Pulse in its own full-screen buffer so the shell's previous
31
46
  // command history does not become part of the chat workspace.
32
47
  alternateScreen: true,
@@ -53,6 +53,7 @@ export async function runResume(options, conversationId, task, format = 'text',
53
53
  }
54
54
  const outcome = await run.outcome();
55
55
  const taskOutcome = await run.taskOutcome();
56
+ const usage = await run.usage();
56
57
  if (format === 'jsonl') {
57
58
  process.stdout.write(`${JSON.stringify({
58
59
  schemaVersion: 1,
@@ -60,6 +61,7 @@ export async function runResume(options, conversationId, task, format = 'text',
60
61
  runId: run.id,
61
62
  status: outcome.status,
62
63
  taskOutcome: taskOutcome ?? null,
64
+ usage,
63
65
  text: outcome.text ?? null,
64
66
  error: outcome.error ?? null,
65
67
  })}\n`);
@@ -1,2 +1,2 @@
1
1
  import { type LocalHostOptions } from '@hunterzhu/pulse-server';
2
- export declare function runOneShot(options: LocalHostOptions, task: string, format?: string, version?: string): Promise<number>;
2
+ export declare function runOneShot(options: LocalHostOptions, task: string, format?: string, version?: string, requirementPaths?: string[]): Promise<number>;
@@ -23,7 +23,7 @@ function writeEvent(event, format, printed) {
23
23
  if (toolLines.length)
24
24
  process.stderr.write(`${toolLines.join('\n')}\n`);
25
25
  }
26
- export async function runOneShot(options, task, format = 'text', version = '0.4.1') {
26
+ export async function runOneShot(options, task, format = 'text', version = '0.4.1', requirementPaths) {
27
27
  const host = createLocalHost(options);
28
28
  let activeRun;
29
29
  const unbindSignals = bindRunSignals(host, () => activeRun);
@@ -32,6 +32,7 @@ export async function runOneShot(options, task, format = 'text', version = '0.4.
32
32
  const conversation = await host.createConversation(options.cwd ? { cwd: options.cwd } : {});
33
33
  const run = await host.sendMessage(conversation.id, {
34
34
  text: task,
35
+ ...(requirementPaths ? { requirementPaths } : {}),
35
36
  format: format === 'jsonl' ? 'jsonl' : 'text',
36
37
  });
37
38
  activeRun = run;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ import { type LocalHostOptions } from '@hunterzhu/pulse-server';
3
3
  export interface AppProps {
4
4
  hostOptions: LocalHostOptions;
5
5
  conversationId?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useRef } from 'react';
3
3
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
+ import { automationDoctor } from '@hunterzhu/pulse-server';
4
5
  import { Header } from './Header.js';
5
6
  import { Welcome } from './Welcome.js';
6
7
  import { MessageList } from './MessageList.js';
@@ -136,6 +137,34 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
136
137
  })();
137
138
  }, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
138
139
  const { commands, executeCommand, isSlashCommand } = useSlashCommands({
140
+ onAutomation: async (args) => {
141
+ if (!host)
142
+ return;
143
+ const report = (text) => addAssistantMessage({ id: `automation-${Date.now()}`, role: 'system', text, createdAt: new Date().toISOString() });
144
+ try {
145
+ const [action = 'status', id] = args.split(/\s+/).filter(Boolean);
146
+ if (action === 'doctor') {
147
+ await report(JSON.stringify(await automationDoctor(hostOptions.automation), null, 2));
148
+ return;
149
+ }
150
+ const sessions = host.automation.list();
151
+ if (action === 'status') {
152
+ await report(JSON.stringify(sessions.map(session => ({ ...session, screenshotPath: session.state === 'closed' ? undefined : host.automation.screenshotPath(session.id) })), null, 2));
153
+ return;
154
+ }
155
+ if (host.getAutomationOptions().approvalMode === 'read-only')
156
+ throw new Error('READ_ONLY');
157
+ const selected = id ?? sessions.find(session => session.state !== 'closed')?.id;
158
+ if (!selected || !['pause', 'takeover', 'resume', 'stop'].includes(action))
159
+ throw new Error('用法:/automation status|doctor|pause|takeover|resume|stop [id]');
160
+ const result = await host.automation.control(selected, action === 'stop' ? 'close' : action, 'local-cli');
161
+ const { lease: _lease, ...status } = result;
162
+ await report(JSON.stringify(status, null, 2) + (action === 'takeover' ? '\nAI 已暂停,可在本机手动操作;完成后 /automation resume。' : ''));
163
+ }
164
+ catch (error) {
165
+ await report(String(error));
166
+ }
167
+ },
139
168
  onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
140
169
  onCopy: async () => {
141
170
  if (selectedText) {
@@ -574,6 +603,6 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
574
603
  }, onInput: () => {
575
604
  setApprovalChoiceFocused(false);
576
605
  setApprovalInputMode(true);
577
- } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
606
+ } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value), onCancel: () => void cancelRun() }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
578
607
  }
579
608
  export default App;
@@ -3,6 +3,7 @@ interface Props {
3
3
  request: AskRequest;
4
4
  disabled?: boolean;
5
5
  onReply: (value: Record<string, unknown>) => void;
6
+ onCancel?: () => void;
6
7
  }
7
- export declare function AskPrompt({ request, disabled, onReply }: Props): import("react").JSX.Element;
8
+ export declare function AskPrompt({ request, disabled, onReply, onCancel }: Props): import("react").JSX.Element;
8
9
  export {};
@@ -1,15 +1,28 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from './TextInput.js';
4
- import { useState } from 'react';
4
+ import { useRef, useState } from 'react';
5
5
  import { theme } from '../theme.js';
6
- export function AskPrompt({ request, disabled = false, onReply }) {
6
+ /** Typed text that cancels the pending question instead of choosing an option. */
7
+ const CANCEL_COMMAND = /^\/?\s*(?:cancel|quit|exit)\s*$/i;
8
+ export function AskPrompt({ request, disabled = false, onReply, onCancel }) {
7
9
  const [value, setValue] = useState(request.defaultValue ?? '');
8
10
  const [inputLines, setInputLines] = useState([]);
9
11
  const [index, setIndex] = useState(0);
10
12
  const [selected, setSelected] = useState([]);
13
+ const [typed, setTyped] = useState('');
14
+ // Mirror of `typed` that updates synchronously, so an Enter arriving in the
15
+ // same batch as a multi-character paste still sees the buffered text.
16
+ const typedRef = useRef('');
17
+ const [hint, setHint] = useState(null);
18
+ const updateTyped = (next) => {
19
+ const value = next(typedRef.current);
20
+ typedRef.current = value;
21
+ setTyped(value);
22
+ };
11
23
  const options = request.options ?? [];
12
24
  const clampedIndex = options.length === 0 ? 0 : Math.min(Math.max(0, index), options.length - 1);
25
+ const isList = request.type === 'choice' || request.type === 'multi';
13
26
  useInput((input, key) => {
14
27
  if (disabled)
15
28
  return;
@@ -23,8 +36,11 @@ export function AskPrompt({ request, disabled = false, onReply }) {
23
36
  }
24
37
  return;
25
38
  }
26
- if ((request.type !== 'choice' && request.type !== 'multi') || options.length === 0)
39
+ if (!isList || options.length === 0)
27
40
  return;
41
+ // Escape already cancels the whole run at the app level; here we add an
42
+ // explicit, text-level cancel so Enter never confirms the default option
43
+ // by accident after the user typed /cancel.
28
44
  if (key.upArrow) {
29
45
  setIndex((current) => Math.max(0, current - 1));
30
46
  return;
@@ -33,25 +49,51 @@ export function AskPrompt({ request, disabled = false, onReply }) {
33
49
  setIndex((current) => Math.min(options.length - 1, current + 1));
34
50
  return;
35
51
  }
36
- if (request.type === 'choice' && key.return) {
37
- const option = options[Math.min(Math.max(0, index), options.length - 1)];
38
- if (option)
39
- onReply({ value: option.value });
52
+ if (key.backspace || key.delete) {
53
+ updateTyped((current) => current.slice(0, -1));
54
+ setHint(null);
40
55
  return;
41
56
  }
42
- if (request.type !== 'multi')
43
- return;
44
- if (input === ' ') {
45
- const option = options[Math.min(Math.max(0, index), options.length - 1)];
57
+ if (request.type === 'multi' && input === ' ') {
58
+ const option = options[clampedIndex];
46
59
  if (!option)
47
60
  return;
48
61
  setSelected((current) => current.includes(option.value) ? current.filter((item) => item !== option.value) : [...current, option.value]);
62
+ return;
49
63
  }
50
- else if (key.return) {
64
+ if (key.return) {
65
+ const pending = typedRef.current.trim();
66
+ if (pending) {
67
+ if (CANCEL_COMMAND.test(pending)) {
68
+ onCancel?.();
69
+ return;
70
+ }
71
+ // A pending line of text must never turn Enter into a silent default
72
+ // selection: clear it and ask the user to choose deliberately.
73
+ updateTyped(() => '');
74
+ setHint('未提交选择:请用 ↑↓ 移动后按 Enter 确认;如需取消请输入 /cancel 或按 Esc。');
75
+ return;
76
+ }
77
+ if (request.type === 'choice') {
78
+ const option = options[clampedIndex];
79
+ if (option)
80
+ onReply({ value: option.value });
81
+ return;
82
+ }
51
83
  if (selected.length < (request.min ?? 0) || (request.max !== undefined && selected.length > request.max))
52
84
  return;
53
85
  onReply({ values: selected });
86
+ return;
87
+ }
88
+ // Buffer the whole printable segment so pasted text such as /cancel is
89
+ // recognised without a dedicated box.
90
+ if (!key.ctrl && !key.meta) {
91
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
92
+ if (printable) {
93
+ updateTyped((current) => Array.from(current + printable).slice(-32).join(''));
94
+ setHint(null);
95
+ }
54
96
  }
55
97
  });
56
- return (_jsxs(Box, { borderStyle: "double", borderColor: theme.primary, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.primary, bold: true, children: ["\u2726 \u9700\u8981\u4F60\u7684\u56DE\u7B54 \u00B7 ", request.toolName] }), _jsx(Text, { color: "white", children: request.prompt }), request.type === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', option.label] }, `${optionIndex}:${option.value}`))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4" })] })), request.type === 'multi' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', selected.includes(option.value) ? '[x] ' : '[ ] ', option.label] }, `${optionIndex}:${option.value}`))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u79FB\u52A8\uFF0C\u7A7A\u683C\u9009\u62E9\uFF0CEnter \u786E\u8BA4", request.min ? `(至少 ${request.min} 项)` : ''] })] })), request.type === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [inputLines.map((line, lineIndex) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(Text, { children: line })] }, `${lineIndex}:${line}`))), _jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(TextInput, { focus: !disabled, value: value, onChange: setValue, placeholder: request.placeholder ?? '' })] })] }))] }));
98
+ return (_jsxs(Box, { borderStyle: "double", borderColor: theme.primary, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.primary, bold: true, children: ["\u2726 \u9700\u8981\u4F60\u7684\u56DE\u7B54 \u00B7 ", request.toolName] }), _jsx(Text, { color: "white", children: request.prompt }), hint ? _jsx(Text, { color: "yellow", children: hint }) : null, isList && typed ? _jsxs(Text, { color: "yellow", children: ["\u5DF2\u8F93\u5165\uFF1A", typed] }) : null, request.type === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', option.label] }, `${optionIndex}:${option.value}`))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4\uFF1BEsc \u6216\u8F93\u5165 /cancel \u53D6\u6D88" })] })), request.type === 'multi' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', selected.includes(option.value) ? '[x] ' : '[ ] ', option.label] }, `${optionIndex}:${option.value}`))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u79FB\u52A8\uFF0C\u7A7A\u683C\u9009\u62E9\uFF0CEnter \u786E\u8BA4", request.min ? `(至少 ${request.min} 项)` : '', "\uFF1BEsc \u6216\u8F93\u5165 /cancel \u53D6\u6D88"] })] })), request.type === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [inputLines.map((line, lineIndex) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(Text, { children: line })] }, `${lineIndex}:${line}`))), _jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(TextInput, { focus: !disabled, value: value, onChange: setValue, placeholder: request.placeholder ?? '' })] })] }))] }));
57
99
  }
package/dist/config.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface PulseCliProviderProfile {
6
6
  apiKeyEnv?: string;
7
7
  maxContextTokens?: number;
8
8
  maxOutputTokens?: number;
9
+ imageInput?: 'supported' | 'unsupported' | 'unknown';
9
10
  reasoningEffort?: 'low' | 'medium' | 'high';
10
11
  toolChoice?: 'auto' | 'required' | 'none' | {
11
12
  type: 'function';
@@ -13,16 +14,19 @@ export interface PulseCliProviderProfile {
13
14
  name: string;
14
15
  };
15
16
  };
17
+ /** Models served by this provider. The provider plus map key is the model identity. */
18
+ models?: Record<string, Omit<PulseCliModel, 'provider'>>;
16
19
  }
17
20
  export interface PulseCliModel {
18
- /** Provider map key, not the adapter id. */
19
- provider: string;
21
+ /** Legacy flat-config field; nested models inherit their provider from the parent. */
22
+ provider?: string;
20
23
  /** Exact model identifier sent to the provider. */
21
24
  modelCode: string;
22
25
  /** Globally unique name shown by the CLI and accepted by `/model`. */
23
- displayName: string;
26
+ displayName?: string;
24
27
  maxContextTokens?: number;
25
28
  maxOutputTokens?: number;
29
+ imageInput?: 'supported' | 'unsupported' | 'unknown';
26
30
  reasoningEffort?: 'low' | 'medium' | 'high';
27
31
  pricing?: {
28
32
  currency: string;
@@ -31,7 +35,7 @@ export interface PulseCliModel {
31
35
  version: string;
32
36
  };
33
37
  }
34
- export type PulseModelTask = 'reason' | 'plan' | 'merge' | 'verify';
38
+ export type PulseModelTask = 'reason' | 'plan' | 'merge' | 'verify' | 'vision';
35
39
  export interface PulseCliMcpServer {
36
40
  command: string;
37
41
  args?: string[];
@@ -45,6 +49,8 @@ export interface PulseCliMcpServer {
45
49
  toolPolicies?: Record<string, 'read' | 'write' | 'external'>;
46
50
  }
47
51
  export interface PulseCliCapabilities {
52
+ browser?: import('@hunterzhu/pulse-server').AutomationConfig['browser'];
53
+ computer?: import('@hunterzhu/pulse-server').AutomationConfig['computer'];
48
54
  /** IDs must be explicitly enabled; workspace config cannot enable host extensions. */
49
55
  enabled?: string[];
50
56
  /** Optional discovery allow-list of skill directory names; omitted discovers all. Bodies load only on invocation. */
@@ -61,18 +67,23 @@ export interface PulseCliConfig {
61
67
  systemPromptFile?: string;
62
68
  /** Named provider profiles. The map key is a stable provider code. */
63
69
  providers?: Record<string, PulseCliProviderProfile>;
64
- /** Globally unique Pulse model names mapped to provider/model codes. */
70
+ /** @deprecated Legacy flat model catalog. New configs nest models under providers. */
65
71
  models?: Record<string, PulseCliModel>;
66
- /** Active Pulse model display name. */
72
+ /** Active Pulse model reference, preferably providerId/modelId. */
67
73
  activeModel?: string;
68
74
  /** Dedicated model for the persistent Pulse assistant chat; defaults to activeModel. */
69
75
  assistantModel?: string;
70
- /** Ordered model display names per task; later entries act as fallbacks. */
76
+ /** Ordered providerId/modelId references per task; later entries act as fallbacks. */
71
77
  taskRouting?: Partial<Record<PulseModelTask, string[]>>;
72
78
  capabilities?: PulseCliCapabilities;
73
79
  approvalMode?: 'read-only' | 'ask' | 'auto';
74
80
  maxTurns?: number;
75
- /** Wall-clock limit for one run. Unset keeps the server default of 15 minutes. */
81
+ /** Provider attempts allowed across one controlled work task; defaults to 256. */
82
+ maxModelCalls?: number;
83
+ modelBudgetPolicy?: import('@hunterzhu/pulse-server').LocalHostOptions['modelBudgetPolicy'];
84
+ persistenceBackend?: 'json' | 'sqlite' | 'external-files';
85
+ maxCostByCurrency?: Record<string, number>;
86
+ /** Optional wall-clock limit for one run. Unset means no overall deadline. */
76
87
  maxRuntimeMs?: number;
77
88
  /** Percent of maxContextTokens that triggers automatic history compaction. Clamped to 1–90. */
78
89
  autoCompactPercent?: number;
package/dist/config.js CHANGED
@@ -3,28 +3,26 @@ import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve, sep } from 'node:path';
4
4
  export const defaultPulseConfig = {
5
5
  providers: {
6
- mock: { provider: 'mock', name: 'Mock' },
6
+ mock: { provider: 'mock', name: 'Mock', models: { mock: { displayName: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' } } },
7
7
  openai: {
8
8
  provider: 'openai-compatible',
9
9
  name: 'OpenAI',
10
10
  baseURL: 'https://api.openai.com/v1',
11
11
  apiKeyEnv: 'OPENAI_API_KEY',
12
+ models: { 'gpt5.6-a': { displayName: 'gpt5.6-a', modelCode: 'gpt-5.6' } },
12
13
  },
13
14
  deepseek: {
14
15
  provider: 'deepseek',
15
16
  name: 'DeepSeek',
16
17
  baseURL: 'https://api.deepseek.com',
17
18
  apiKeyEnv: 'DEEPSEEK_API_KEY',
19
+ models: { 'deepseek-chat': { displayName: 'deepseek-chat', modelCode: 'deepseek-chat' } },
18
20
  },
19
21
  },
20
- models: {
21
- mock: { displayName: 'mock', provider: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' },
22
- 'gpt5.6-a': { displayName: 'gpt5.6-a', provider: 'openai', modelCode: 'gpt-5.6' },
23
- 'deepseek-chat': { displayName: 'deepseek-chat', provider: 'deepseek', modelCode: 'deepseek-chat' },
24
- },
25
- activeModel: 'mock',
22
+ activeModel: 'mock/mock',
26
23
  approvalMode: 'ask',
27
24
  maxTurns: 32,
25
+ maxModelCalls: 256,
28
26
  autoCompactPercent: 90,
29
27
  allowNetwork: false,
30
28
  jarvis: {
@@ -82,6 +80,13 @@ export function sanitizeWorkspaceConfig(value) {
82
80
  const safe = {
83
81
  provider: profile.provider,
84
82
  ...(profile.name === undefined ? {} : { name: profile.name }),
83
+ ...(profile.models === undefined ? {} : { models: Object.fromEntries(Object.entries(profile.models).flatMap(([modelId, model]) => model.modelCode ? [[modelId, {
84
+ modelCode: model.modelCode,
85
+ ...(model.displayName === undefined ? {} : { displayName: model.displayName }),
86
+ ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
87
+ ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
88
+ ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
89
+ }]] : [])) }),
85
90
  };
86
91
  return Object.keys(safe).length ? [[name, safe]] : [];
87
92
  }));
@@ -100,7 +105,7 @@ export function sanitizeWorkspaceConfig(value) {
100
105
  return [[name, {
101
106
  provider: model.provider,
102
107
  modelCode: model.modelCode,
103
- displayName: model.displayName,
108
+ ...(model.displayName === undefined ? {} : { displayName: model.displayName }),
104
109
  ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
105
110
  ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
106
111
  ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
@@ -113,10 +118,19 @@ export function mergePulseConfigs(layers) {
113
118
  let merged = {};
114
119
  for (const layer of layers) {
115
120
  const value = layer.trust === 'workspace' ? sanitizeWorkspaceConfig(layer.value) : layer.value;
121
+ const providers = { ...(merged.providers ?? {}) };
122
+ for (const [providerId, profile] of Object.entries(value.providers ?? {})) {
123
+ const previous = providers[providerId];
124
+ providers[providerId] = {
125
+ ...previous,
126
+ ...profile,
127
+ ...(previous?.models === undefined && profile.models === undefined ? {} : { models: { ...previous?.models, ...profile.models } }),
128
+ };
129
+ }
116
130
  merged = {
117
131
  ...merged,
118
132
  ...value,
119
- ...(merged.providers === undefined && value.providers === undefined ? {} : { providers: { ...merged.providers, ...value.providers } }),
133
+ ...(merged.providers === undefined && value.providers === undefined ? {} : { providers }),
120
134
  ...(merged.models === undefined && value.models === undefined ? {} : { models: { ...merged.models, ...value.models } }),
121
135
  };
122
136
  }
@@ -1,5 +1,6 @@
1
1
  import type { SlashCommand } from '../types.js';
2
2
  export interface SlashCommandDependencies {
3
+ onAutomation?: (args: string) => void | Promise<void>;
3
4
  onMouse?: (args: string) => void | Promise<void>;
4
5
  onCopy?: () => void | Promise<void>;
5
6
  onHelp?: () => void | Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useCallback } from 'react';
2
2
  export function useSlashCommands(deps) {
3
3
  const commands = useMemo(() => [
4
+ { name: '/automation', description: '自动化会话 [status|pause|takeover|resume|stop] [id]', execute: async (args) => deps.onAutomation?.(args) },
4
5
  { name: '/mouse', description: '滚轮和触控板滚动 [on|off]', execute: async (args) => deps.onMouse?.(args) },
5
6
  { name: '/copy', description: '复制选区或最近一条完整回复', execute: async () => deps.onCopy?.() },
6
7
  { name: '/help', aliases: ['/h'], description: '显示帮助信息', execute: async () => deps.onHelp?.() },
@@ -1,5 +1,61 @@
1
1
  // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
2
2
  export const releaseHighlights = {
3
+ "0.6.0": [
4
+ {
5
+ "zh": "新增受管理的浏览器与桌面自动化,支持页面观察、截图操作、上传下载、暂停及人工接管,并在 CLI 和 H5 中提供入口。",
6
+ "en": "Added managed browser and desktop automation with page observation, screenshot actions, uploads and downloads, pause, and human takeover through CLI and H5 controls."
7
+ },
8
+ {
9
+ "zh": "Web 工作台展示任务执行过程、阶段进度和工具调用详情,便于查看运行状态与审批内容。",
10
+ "en": "The Web workbench displays task execution traces, stage progress, and tool-call details to make run status and approval requests easier to inspect."
11
+ },
12
+ {
13
+ "zh": "改善长任务的上下文压缩、工具输入恢复、循环检测和预算处理,保留中断续跑的状态及调用记录。",
14
+ "en": "Improved context compaction, tool-input recovery, loop detection, and budget handling for long tasks, preserving state and call accounting across interrupted runs."
15
+ },
16
+ {
17
+ "zh": "按明确的并行要求调度独立阶段,修复并行工作单元的状态访问与依赖证据传递。",
18
+ "en": "Schedule independent stages in parallel when explicitly requested, and fix worker state access and dependency evidence propagation."
19
+ },
20
+ {
21
+ "zh": "保存计划后执行与内容绑定的确认,计划变更需重新确认;审批继续保留人工回退和操作范围限制。",
22
+ "en": "Bind execution confirmation to the saved plan and require confirmation again after changes; approvals retain human fallback and operation scope checks."
23
+ },
24
+ {
25
+ "zh": "保护原有测试与验收命令,在修改前阻止破坏性补丁,同时允许授权的新测试补充和修正。",
26
+ "en": "Protect original tests and acceptance commands by rejecting destructive patches before writes, while allowing authorized additions and corrections to new tests."
27
+ },
28
+ {
29
+ "zh": "修复验证证据的新鲜度、无效引用恢复及已通过测试覆盖先前失败子集的判定,减少重复验证与错误阻塞。",
30
+ "en": "Fix evidence freshness, recovery from invalid citations, and recognition of passing test supersets that cover earlier failed subsets, reducing redundant checks and false blocks."
31
+ },
32
+ {
33
+ "zh": "修复 Windows 安装依赖只读隔离、相邻源码的读取边界、脚本审批路径显示及沙箱权限分批初始化,减少无关脚本的递归依赖授权开销,完善跨平台回归验证。",
34
+ "en": "Fix Windows read-only isolation for installed dependencies, read boundaries for adjacent source, script approval path display, and batched sandbox permission initialization; reduce recursive dependency grants for unrelated scripts and improve cross-platform regression checks."
35
+ },
36
+ {
37
+ "zh": "H5 停止服务时等待正在启动的助理运行完成清理,保留后续排队消息;会话文件统一原子写入,修复并发读取异常和请求挂起。",
38
+ "en": "H5 shutdown waits for assistant launches already in progress to settle and preserves later queued messages; atomic conversation writes prevent concurrent read errors and hanging requests."
39
+ }
40
+ ],
41
+ "0.5.1": [
42
+ {
43
+ "zh": "模型目录按供应商管理,支持不同供应商登记同名模型;默认模型、助理聊天、任务路由、即时预回复和审批复核统一选择已登记模型。",
44
+ "en": "Models are managed under providers, allowing different providers to register the same model name; defaults, assistant chat, task routing, instant replies, and approval review all select registered models."
45
+ },
46
+ {
47
+ "zh": "项目侧栏支持组织会话与项目,并完善工作区信任边界及模型配置迁移。",
48
+ "en": "Added project sidebar organization and improved workspace trust boundaries and model configuration migration."
49
+ },
50
+ {
51
+ "zh": "即时预回复按所选供应商模型读取协议与连接配置;审批复核继续限制为本机 Ollama,并在异常时交回人工。",
52
+ "en": "Instant replies use the selected provider's protocol and connection settings; approval review remains restricted to local Ollama and falls back to human review on errors."
53
+ },
54
+ {
55
+ "zh": "改善 Jarvis 经验记录、上下文筛选和持久化交付流程。",
56
+ "en": "Improved Jarvis experience recording, context filtering, and durable delivery workflows."
57
+ }
58
+ ],
3
59
  "0.5.0": [
4
60
  {
5
61
  "zh": "新增独立 Web 工作台,统一助理聊天、项目工作会话、历史记录、设置与本地终端。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "registry": "https://registry.npmjs.org"
37
37
  },
38
38
  "dependencies": {
39
- "@hunterzhu/pulse-server": "0.5.0",
39
+ "@hunterzhu/pulse-server": "0.6.0",
40
40
  "chalk": "^5.4.1",
41
41
  "highlight.js": "^11.10.0",
42
42
  "ink": "^7.1.1",