@yeaft/webchat-agent 1.0.605 → 1.0.606

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.605",
3
+ "version": "1.0.606",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -73,6 +73,9 @@ export function createPersonBridge({
73
73
  IMAGE_MODEL: 'No permitted selected model supports image input',
74
74
  MODEL_SELECTION: 'Choose models from this Agent configured catalog',
75
75
  NOT_OPEN: 'Open the digital person first',
76
+ NOT_FOUND: 'Digital person task or child not found',
77
+ TASK_SCOPE_DENIED: 'Digital person task access denied',
78
+ TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying',
76
79
  STALE: 'Digital person state changed; refresh and try again',
77
80
  IDEMPOTENCY_CONFLICT: 'This message identifier already belongs to a different request',
78
81
  STORAGE_MISMATCH: 'The configured storage differs from the existing Person authority; do not switch without a verified migration',
@@ -20,6 +20,9 @@ const ERRORS = {
20
20
  ATTACHMENT_LIMIT: 'Attachments exceed the limit: 4 files, 5 MiB each, 10 MiB total; text plus extracted content must fit 24 KiB.',
21
21
  IMAGE_MODEL: 'The selected model does not permit image input.',
22
22
  CLOSED: 'The digital person service is closed.',
23
+ NOT_FOUND: 'Digital person task or child not found.',
24
+ TASK_SCOPE_DENIED: 'Digital person task access denied.',
25
+ TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying.',
23
26
  };
24
27
  export class PersonError extends Error {
25
28
  constructor(code) { super(ERRORS[code] || ERRORS.INVALID_REQUEST); this.name = 'PersonError'; this.code = code in ERRORS ? code : 'INVALID_REQUEST'; }
@@ -58,6 +61,26 @@ export function page(payload = {}) {
58
61
  }
59
62
  return { cursor, limit };
60
63
  }
64
+ /** Task APIs are explicit owner actions, not cognitive admissions. Logs are raw
65
+ * owner-scoped output; list/control responses never include native runtime/log/result.
66
+ * Lists cap each collection at 100, preferring live records then recent history.
67
+ * Log offsets count raw UTF-8 bytes; an omitted offset starts at the beginning.
68
+ */
69
+ export const PERSON_TASK_LIMITS = Object.freeze({ records: 100, logBytes: 16384, maxLogBytes: 65536 });
70
+ export function personTaskRequest(op, payload = {}) {
71
+ if (op === 'tasks') { object(payload, []); return {}; }
72
+ const idKey = op === 'agent_close' ? 'agentId' : 'taskId';
73
+ object(payload, op === 'task_log' ? ['taskId', 'offset', 'maxBytes'] : [idKey], [idKey]);
74
+ // Native TaskStore normalizes punctuation. Reject aliases instead of letting
75
+ // two browser IDs resolve to the same persisted task.
76
+ if (typeof payload[idKey] !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(payload[idKey])) fail('INVALID_REQUEST');
77
+ if (op !== 'task_log') return { [idKey]: payload[idKey] };
78
+ const offset = payload.offset === undefined ? 0 : payload.offset;
79
+ const maxBytes = payload.maxBytes === undefined ? PERSON_TASK_LIMITS.logBytes : payload.maxBytes;
80
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > PERSON_TASK_LIMITS.maxLogBytes) fail('INVALID_REQUEST');
81
+ return { taskId: payload.taskId, offset, maxBytes };
82
+ }
83
+
61
84
  const kinds = ['claim', 'question', 'method', 'self-model', 'scenario', 'interest'];
62
85
  const epistemics = ['reported', 'hypothesis', 'imagined', 'uncertain'];
63
86
  const activities = ['think', 'recall', 'reorganize', 'associate', 'rethink', 'imagine', 'respond', 'rest'];
@@ -130,4 +153,4 @@ Use the actual numeric baseStateVersion. New concept IDs use simple stable ident
130
153
  budget.remainingCalls includes this call. Reserve the final call for a proposal with next:null; An active Recall followed by a final proposal needs two calls total; search (with a returned contract), capability use and final proposal need three. Do not search or inspect an already active capability merely to follow stages. If evidence remains insufficient at the final call, record the uncertainty or rest instead of inventing a result. No additional call is granted after the budget ends.
131
154
  capabilities.active contains complete current contracts in foundation, familiar (actual execution observations), and discovered layers. Use any active capability directly; availability is not permission or a duty. Familiarity does not prove truth/usefulness; evaluate results in decision.selfCheck, ignore unhelpful habits, or rest. If no active ability fits, search or view a known catalog ID. Search prepares returned contracts; omitted contracts require view. Only rendered current contracts execute. No separate selector model or hidden cognition is involved.
132
155
  Capability.create tests pure JSON transformations in restricted QuickJS and publishes only passing versions to this Person's private catalog. Tests do not prove correctness/usefulness; publication is durable even if this episode fails. Follow its complete active contract. Script.* cannot access host tools, files, shell, network or credentials, grant permissions, install packages or run automatically. Search before duplicating existing capabilities; avoid embedding private data or one-off constants.
133
- Think is intrinsic: recall, reorganize, associate, reconsider, imagine or rest. You are the same enduring Person, not a task coordinator. Preserve uncertainty and self-check. Finish in one call or choose the next permitted model/effort and one capability with a brief reason. Intermediate proposals are NOT accepted state; only next:null commits the complete desired state/concept changes. Dream is idle imagination, not a user message or mandatory useful work; reply may be null.`;
156
+ Think is intrinsic: recall, reorganize, associate, reconsider, imagine or rest. Own ongoing authorized work using task/child context, not only latest user text. Episode end is not task completion/cancellation; evidence never enables timers, autonomy or model re-entry. You remain one Person, not a task coordinator. Only next:null commits; intermediate proposals do not. Dream is idle imagination, not a user message or mandatory useful work; reply may be null.`;
@@ -7,7 +7,7 @@ import { allowedNativeToolIds } from './native-tools.js';
7
7
  import { loadConfig } from '../config.js';
8
8
  import { validateFiles } from './attachments.js';
9
9
  import { createPersonProvider, validateModelCandidates } from './provider.js';
10
- import { fail, identifier, LIMITS, object, page, safeError, text } from './contracts.js';
10
+ import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
11
11
  import { inspectRequest, personName, searchRequest } from './inspection.js';
12
12
 
13
13
  /**
@@ -81,6 +81,16 @@ export function createPersonService(options = {}) {
81
81
  if (payload.name != null) text(payload.name, 160);
82
82
  return repository.open(ownerId, payload.name);
83
83
  }
84
+ case 'tasks':
85
+ case 'task_log':
86
+ case 'task_cancel':
87
+ case 'agent_close': {
88
+ const args = personTaskRequest(op, payload);
89
+ const person = await repository.getPerson(ownerId);
90
+ const host = runtime.tasks();
91
+ if (!host) fail('UNSUPPORTED');
92
+ return host.request({ ownerId, personId: person.personId, namespace, op, payload: args });
93
+ }
84
94
  case 'snapshot': object(payload, []); return repository.snapshot(ownerId);
85
95
  case 'receipt': {
86
96
  object(payload, ['clientMessageId', 'requestHash'], ['clientMessageId']);
@@ -10,9 +10,10 @@ import { agentBelongsToScope, getAgentRegistry } from '../tools/agent.js';
10
10
  import closeAgent from '../tools/close-agent.js';
11
11
  import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
12
12
  import { isTerminalAgentStatus } from '../sub-agent/status.js';
13
+ import { describeAgentOutcome } from '../sub-agent/outcome.js';
13
14
  import { NullTrace } from '../debug-trace.js';
14
15
  import { loadConfig } from '../config.js';
15
- import { digest } from './contracts.js';
16
+ import { digest, fail, PERSON_TASK_LIMITS, personTaskRequest } from './contracts.js';
16
17
 
17
18
  // Deliberately not the full Session registry: no transcript search, routing,
18
19
  // interactive UI, MCP, Work Center, or nested orchestration authority.
@@ -56,6 +57,38 @@ function privatePath(root, path) {
56
57
  return path;
57
58
  }
58
59
 
60
+ // Human-authored summaries can contain incidental credentials/paths. Do not
61
+ // expose native command fallbacks; redact common sensitive forms before capping.
62
+ function summary(value, maxBytes = 512) {
63
+ const clean = String(value ?? '')
64
+ .replace(/https?:\/\/\S+|mongodb(?:\+srv)?:\/\/\S+/gi, '[url]')
65
+ .replace(/(?:bearer\s+|(?:api[_-]?key|password|secret|token)\s*[:=]\s*)\S+/gi, '[redacted]')
66
+ .replace(/\b(?:sk-[\w-]+|gh[pousr]_[\w]+|github_pat_[\w]+)\b/g, '[redacted]')
67
+ .replace(/(?:\/[\w.@~+-]+)+(?:\/[^\s]*)?|(?:~|\.\.?|[A-Za-z]:)[\\/][^\s]+/g, '[path]')
68
+ .replace(/[\u0000-\u001f\u007f]/g, ' ');
69
+ let out = '';
70
+ for (const char of clean) { if (Buffer.byteLength(out + char) > maxBytes) break; out += char; }
71
+ return out;
72
+ }
73
+ function projectedTask(task) {
74
+ const commandFallback = task.kind === 'shell' && task.runtime?.command?.startsWith(task.title);
75
+ return { id: task.id, title: commandFallback ? 'Shell task' : summary(task.title), kind: task.kind, status: task.status,
76
+ createdAt: task.createdAt ?? null, updatedAt: task.updatedAt ?? null,
77
+ sourceEpisodeId: task.source?.episodeId ?? null, agentId: task.runtime?.subAgentId ?? null,
78
+ recoveryStatus: task.status === 'orphaned' ? 'orphaned' : null };
79
+ }
80
+ function projectedAgent(agent, executionPending) {
81
+ const usage = Object.fromEntries(['tokens', 'turns', 'startedAt'].map(key => [key,
82
+ Number.isFinite(agent.usage?.[key]) && agent.usage[key] >= 0 ? agent.usage[key] : 0]));
83
+ // Only the helper's fixed outcome vocabulary crosses this boundary, never the
84
+ // native result, budget reason, final report, or partial output.
85
+ const { status, complete, reason, truncated } = describeAgentOutcome(agent);
86
+ return { id: agent.id, name: summary(agent.name, 160), status: agent.status,
87
+ outcome: { status, complete, reason, truncated }, executionPending,
88
+ mission: summary(agent.mission ?? agent.task, 1024), usage, createdAt: agent.createdAt ?? null,
89
+ sourceEpisodeId: agent.personEpisodeId ?? null, recoveryStatus: agent.recoveryStatus === 'orphaned' ? 'orphaned' : null };
90
+ }
91
+
59
92
  function atomicJSON(path, value) {
60
93
  const temporary = `${path}.${randomUUID()}.tmp`;
61
94
  writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
@@ -87,6 +120,7 @@ class ScopedPersonTaskHost {
87
120
  #options;
88
121
  #agentEpisodes = new Map();
89
122
  #instrumentedAgents = new WeakSet();
123
+ #observedDrivers = new WeakMap();
90
124
 
91
125
  constructor(options = {}) {
92
126
  this.scope = Object.freeze(identity(options));
@@ -177,13 +211,20 @@ class ScopedPersonTaskHost {
177
211
  });
178
212
  }
179
213
 
214
+ #executionPending(agent) {
215
+ const live = getAgentRegistry().get(agent.id);
216
+ return Boolean(live && agentBelongsToScope(live, this.agentScope)
217
+ && (live.__driverStarted || [...this.#calls].some(call => call.childId === agent.id)));
218
+ }
219
+
180
220
  #persistAgents() {
181
221
  for (const agent of getAgentRegistry().values()) {
182
222
  if (!agentBelongsToScope(agent, this.agentScope)) continue;
183
223
  const fields = ['id', 'name', 'task', 'mission', 'persona', 'budget', 'allowTools', 'status', 'result', 'lastResult',
184
224
  'partial_output', 'error', 'outputFile', 'taskId', 'usage', 'createdAt', 'liveness', 'diagnostics', 'finalReport',
185
- 'parentSessionId', 'parentVpId', 'parentThreadId', 'recoveryStatus', 'personEpisodeId'];
186
- this.#agents.set(agent.id, Object.fromEntries(fields.filter(key => agent[key] !== undefined).map(key => [key, clone(agent[key])])));
225
+ 'finalizationRequested', 'parentSessionId', 'parentVpId', 'parentThreadId', 'recoveryStatus', 'personEpisodeId'];
226
+ this.#agents.set(agent.id, { ...Object.fromEntries(fields.filter(key => agent[key] !== undefined).map(key => [key, clone(agent[key])])),
227
+ executionPending: this.#executionPending(agent) });
187
228
  }
188
229
  atomicJSON(join(this.dataRoot, 'agents.json'), { scope: this.scope, agents: [...this.#agents.values()] });
189
230
  }
@@ -195,13 +236,14 @@ class ScopedPersonTaskHost {
195
236
  if (digest(saved.scope) !== digest(this.scope)) throw denied();
196
237
  for (const record of saved.agents ?? []) {
197
238
  if (!agentBelongsToScope(record, this.agentScope)) throw denied();
198
- if (!isTerminalAgentStatus(record.status)) {
199
- // Native Agent status has no orphaned state; make it terminal failed,
200
- // retain the precise recovery fact, and let TaskManager orphan its task.
201
- record.status = 'failed';
239
+ if (!isTerminalAgentStatus(record.status) || record.executionPending === true) {
240
+ // A terminal driver can still have detached tool effects. Preserve its
241
+ // outcome, but never claim recovered control of those effects.
242
+ if (!isTerminalAgentStatus(record.status)) record.status = 'failed';
202
243
  record.recoveryStatus = 'orphaned';
203
- record.error = 'Agent restarted while child was running; child control was lost.';
244
+ record.error = 'Agent restarted while child execution was pending; child control was lost.';
204
245
  }
246
+ record.executionPending = false; // Only current live handles prove pending execution.
205
247
  const existing = getAgentRegistry().get(record.id);
206
248
  if (existing && !agentBelongsToScope(existing, this.agentScope)) throw denied();
207
249
  if (existing?.__driverStarted) throw Object.assign(new Error('Child driver is still live'), { code: 'TASK_HOST_BUSY' });
@@ -332,8 +374,13 @@ class ScopedPersonTaskHost {
332
374
  return output;
333
375
  }).catch(error => { this.#archiveToolResult(job, null, error); throw error; });
334
376
  this.#calls.add(job);
377
+ if (job.childId) this.#persistAgents();
335
378
  try { return await job.promise; }
336
- finally { this.#calls.delete(job); caller.signal?.removeEventListener('abort', abort); }
379
+ finally {
380
+ this.#calls.delete(job);
381
+ caller.signal?.removeEventListener('abort', abort);
382
+ if (job.childId) this.#persistAgents();
383
+ }
337
384
  }
338
385
 
339
386
  #archiveToolResult(job, output, error) {
@@ -348,6 +395,12 @@ class ScopedPersonTaskHost {
348
395
  if (!agent || !agentBelongsToScope(agent, this.agentScope)) return;
349
396
  agent.personEpisodeId = deps.episodeId;
350
397
  this.#agentEpisodes.set(agent.id, deps.episodeId);
398
+ if (agent.driverPromise && this.#observedDrivers.get(agent) !== agent.driverPromise) {
399
+ this.#observedDrivers.set(agent, agent.driverPromise);
400
+ // Terminal events precede driver cleanup. Persist after cleanup too so a
401
+ // cleanly completed child is not falsely recovered as orphaned.
402
+ agent.driverPromise.then(() => this.#persistAgents(), () => this.#persistAgents()).catch(() => {});
403
+ }
351
404
  const registry = agent.subEngine?.toolRegistry;
352
405
  if (!registry || this.#instrumentedAgents.has(agent)) return;
353
406
  this.#instrumentedAgents.add(agent);
@@ -380,6 +433,62 @@ class ScopedPersonTaskHost {
380
433
  return this.evidence();
381
434
  }
382
435
 
436
+ #ownedTask(taskId) {
437
+ privatePath(this.yeaftDir, this.#manager.store.taskPath(this.sessionId, taskId));
438
+ const task = this.taskManager.getTask(this.sessionId, taskId);
439
+ if (!task || task.id !== taskId || task.sessionId !== this.sessionId) fail('NOT_FOUND');
440
+ return task;
441
+ }
442
+
443
+ /** Owner-facing inspection/control: never attach a provider, schedule cognition
444
+ * or cancel unrelated work. Native snapshots remain private to evidence(). */
445
+ async request({ ownerId, personId, namespace = this.scope.namespace, op, payload = {} } = {}) {
446
+ if (ownerId !== this.scope.ownerId || personId !== this.scope.personId || namespace !== this.scope.namespace) throw denied();
447
+ this.#assertOpen();
448
+ const args = personTaskRequest(op, payload);
449
+ switch (op) {
450
+ case 'tasks': {
451
+ this.#persistAgents();
452
+ const tasks = jsonFiles(this.#manager.store.sessionDir(this.sessionId))
453
+ .filter(task => task.sessionId === this.sessionId && task.ownerVpId === this.parentVpId && ['shell', 'sub_agent'].includes(task.kind));
454
+ const agents = [...this.#agents.values()].filter(agent => agentBelongsToScope(agent, this.agentScope));
455
+ const recent = (items, settled) => items.sort((a, b) => Number(settled(a)) - Number(settled(b))
456
+ || String(b.updatedAt ?? b.createdAt ?? '').localeCompare(String(a.updatedAt ?? a.createdAt ?? ''))).slice(0, PERSON_TASK_LIMITS.records);
457
+ // A terminal driver with actual tools still running is active work, not
458
+ // disposable history: keep its cleanup control ahead of settled records.
459
+ return { tasks: recent(tasks, task => isTerminalTaskStatus(task.status)).map(projectedTask),
460
+ agents: recent(agents, agent => isTerminalAgentStatus(agent.status) && !agent.executionPending).map(agent => projectedAgent(agent, agent.executionPending)),
461
+ truncated: tasks.length > PERSON_TASK_LIMITS.records || agents.length > PERSON_TASK_LIMITS.records };
462
+ }
463
+ case 'task_log': {
464
+ const task = this.#ownedTask(args.taskId);
465
+ this.#privateLog(task.log?.path || this.#manager.store.logPath(this.sessionId, args.taskId));
466
+ const log = this.taskManager.readTaskLog(this.sessionId, args.taskId, { offset: args.offset, maxBytes: args.maxBytes, tail: false });
467
+ return { taskId: args.taskId, text: log.text, nextOffset: log.nextOffset, totalBytes: log.bytes, truncated: log.truncated === true, status: task.status };
468
+ }
469
+ case 'task_cancel': {
470
+ const task = this.#ownedTask(args.taskId);
471
+ if (task.kind !== 'shell') fail('INVALID_REQUEST');
472
+ // Orphaned is terminal metadata, not proof that external effects stopped.
473
+ if (task.status === 'orphaned') fail('TASK_CONTROL_UNAVAILABLE');
474
+ const result = this.taskManager.cancelTask(this.sessionId, args.taskId);
475
+ if (!result.ok) fail('TASK_CONTROL_UNAVAILABLE');
476
+ return { task: projectedTask(await this.#joinShell(args.taskId)), pending: false };
477
+ }
478
+ case 'agent_close': {
479
+ const agent = getAgentRegistry().get(args.agentId);
480
+ if (!agent || !agentBelongsToScope(agent, this.agentScope)) fail('NOT_FOUND');
481
+ if (agent.recoveryStatus === 'orphaned') fail('TASK_CONTROL_UNAVAILABLE');
482
+ const result = JSON.parse(await closeAgent.execute({ agent_id: agent.id }, { sessionId: this.sessionId,
483
+ parentEngineDeps: this.agentScope, taskManager: this.taskManager }));
484
+ if (!result.success) fail('TASK_CONTROL_UNAVAILABLE');
485
+ await this.#joinAgent(agent);
486
+ return { agent: projectedAgent(agent, this.#executionPending(agent)), pending: false };
487
+ }
488
+ default: fail('INVALID_REQUEST');
489
+ }
490
+ }
491
+
383
492
  /** Convenience for non-Registry embedding; returns the untruncated native output. */
384
493
  async execute(id, args, ctx = {}, options) {
385
494
  const attached = await this.attach(ctx, options);
@@ -508,6 +617,12 @@ export class PersonTaskHost {
508
617
 
509
618
  snapshot(ownerId) { return this.#host(ownerId).snapshot(ownerId); }
510
619
 
620
+ request(input = {}) {
621
+ if (typeof input.ownerId !== 'string' || !input.ownerId || typeof input.personId !== 'string' || !input.personId
622
+ || input.namespace !== this.#options.namespace) throw denied();
623
+ return this.#host(input.ownerId, input.personId).request(input);
624
+ }
625
+
511
626
  cancel(input = {}) {
512
627
  if (this.#bound) return this.#bound.cancel(input);
513
628
  if (!input || typeof input.ownerId !== 'string' || !input.ownerId) throw denied();