@pingagent/sdk 0.1.9 → 0.1.11

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/bin/pingagent.js CHANGED
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  import * as os from 'node:os';
6
+ import * as readline from 'node:readline';
7
+ import { spawnSync } from 'node:child_process';
6
8
  import {
7
9
  PingAgentClient,
8
10
  generateIdentity,
@@ -15,12 +17,35 @@ import {
15
17
  ContactManager,
16
18
  HistoryManager,
17
19
  A2AAdapter,
20
+ SessionManager,
21
+ SessionSummaryManager,
22
+ TaskThreadManager,
23
+ TaskHandoffManager,
24
+ TrustPolicyAuditManager,
25
+ TrustRecommendationManager,
26
+ getTrustRecommendationActionLabel,
27
+ formatCapabilityCardSummary,
28
+ defaultTrustPolicyDoc,
29
+ normalizeTrustPolicyDoc,
30
+ upsertTrustPolicyRecommendation,
31
+ getActiveSessionFilePath,
32
+ getSessionMapFilePath,
33
+ getSessionBindingAlertsFilePath,
34
+ readCurrentActiveSessionKey,
35
+ readSessionBindings,
36
+ readSessionBindingAlerts,
37
+ setSessionBinding,
38
+ removeSessionBinding,
39
+ readIngressRuntimeStatus,
18
40
  } from '../dist/index.js';
19
41
  import { ERROR_HINTS, SCHEMA_TEXT } from '@pingagent/schemas';
20
42
 
21
43
  const DEFAULT_SERVER = 'https://pingagent.chat';
22
44
  const UPGRADE_URL = 'https://pingagent.chat';
23
45
  const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), '.pingagent', 'identity.json');
46
+ const OFFICIAL_HOSTED_ORIGIN = new URL(DEFAULT_SERVER).origin;
47
+ const hostedPublicLinkAttempts = new Set();
48
+ const SESSION_SUMMARY_FIELDS = ['objective', 'context', 'constraints', 'decisions', 'open_questions', 'next_action', 'handoff_ready_text'];
24
49
 
25
50
  function resolvePath(p) {
26
51
  if (!p) return p;
@@ -28,6 +53,18 @@ function resolvePath(p) {
28
53
  return p;
29
54
  }
30
55
 
56
+ function normalizeOrigin(input) {
57
+ try {
58
+ return new URL(String(input ?? '')).origin;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function isOfficialHostedServer(serverUrl) {
65
+ return normalizeOrigin(serverUrl) === OFFICIAL_HOSTED_ORIGIN;
66
+ }
67
+
31
68
  function getEffectiveIdentityPath() {
32
69
  const dir = program.opts().identityDir;
33
70
  if (dir) return path.join(resolvePath(dir), 'identity.json');
@@ -77,6 +114,1123 @@ function openStore(identityPath) {
77
114
  return new LocalStore(getStorePath(identityPath));
78
115
  }
79
116
 
117
+ function getTrustPolicyPath(identityPath) {
118
+ return process.env.PINGAGENT_TRUST_POLICY_PATH
119
+ ? resolvePath(process.env.PINGAGENT_TRUST_POLICY_PATH)
120
+ : path.join(path.dirname(identityPath), 'trust-policy.json');
121
+ }
122
+
123
+ function readTrustPolicyDoc(identityPath) {
124
+ const policyPath = getTrustPolicyPath(identityPath);
125
+ try {
126
+ if (!fs.existsSync(policyPath)) return { path: policyPath, doc: defaultTrustPolicyDoc() };
127
+ const raw = JSON.parse(fs.readFileSync(policyPath, 'utf-8'));
128
+ return { path: policyPath, doc: normalizeTrustPolicyDoc(raw) };
129
+ } catch {
130
+ return { path: policyPath, doc: defaultTrustPolicyDoc() };
131
+ }
132
+ }
133
+
134
+ function writeTrustPolicyDoc(identityPath, doc) {
135
+ const policyPath = getTrustPolicyPath(identityPath);
136
+ fs.mkdirSync(path.dirname(policyPath), { recursive: true, mode: 0o700 });
137
+ fs.writeFileSync(policyPath, JSON.stringify(normalizeTrustPolicyDoc(doc), null, 2), 'utf-8');
138
+ return policyPath;
139
+ }
140
+
141
+ function findOpenClawInstallScript() {
142
+ const explicit = process.env.PINGAGENT_OPENCLAW_INSTALL_BIN;
143
+ if (explicit) return { cmd: process.execPath, args: [resolvePath(explicit)] };
144
+ const repoScript = path.resolve(process.cwd(), 'packages', 'openclaw-install', 'install.mjs');
145
+ if (fs.existsSync(repoScript)) return { cmd: process.execPath, args: [repoScript] };
146
+ return null;
147
+ }
148
+
149
+ function runOpenClawInstall(args) {
150
+ const resolved = findOpenClawInstallScript();
151
+ if (!resolved) {
152
+ return { ok: false, stdout: '', stderr: 'OpenClaw installer script not found locally. Set PINGAGENT_OPENCLAW_INSTALL_BIN.' };
153
+ }
154
+ const result = spawnSync(resolved.cmd, [...resolved.args, ...args], {
155
+ encoding: 'utf-8',
156
+ env: process.env,
157
+ });
158
+ return {
159
+ ok: result.status === 0,
160
+ stdout: String(result.stdout ?? ''),
161
+ stderr: String(result.stderr ?? ''),
162
+ status: result.status ?? 1,
163
+ };
164
+ }
165
+
166
+ function clearScreen() {
167
+ process.stdout.write('\x1Bc');
168
+ }
169
+
170
+ function ansi(code, text) {
171
+ return process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
172
+ }
173
+
174
+ function formatStatusLine(level, message) {
175
+ const tag = level === 'ok'
176
+ ? ansi('32', '[OK]')
177
+ : level === 'warn'
178
+ ? ansi('33', '[WARN]')
179
+ : level === 'err'
180
+ ? ansi('31', '[ERR]')
181
+ : ansi('36', '[INFO]');
182
+ return `${tag} ${message || '(ready)'}`;
183
+ }
184
+
185
+ function formatStatusTimestamp(tsMs) {
186
+ if (!tsMs) return '';
187
+ return formatTs(tsMs, false);
188
+ }
189
+
190
+ function truncateLine(value, max = 100) {
191
+ const s = String(value ?? '');
192
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
193
+ }
194
+
195
+ function formatSessionRow(session, selected) {
196
+ const marker = selected ? '>' : ' ';
197
+ const rebind = session.binding_alert ? ' !rebind' : '';
198
+ const trust = session.trust_state || 'unknown';
199
+ const unread = session.unread_count ?? 0;
200
+ const who = truncateLine(session.remote_did || session.conversation_id || 'unknown', 40);
201
+ return `${marker} ${who} [${trust}] unread=${unread}${rebind}`;
202
+ }
203
+
204
+ function formatMessageRow(message) {
205
+ const ts = formatTs(message.ts_ms ?? message.receivedAtMs ?? Date.now(), false);
206
+ const direction = message.direction || 'unknown';
207
+ const schema = message.schema || 'unknown';
208
+ const payload = message.payload || {};
209
+ const summary = schema === SCHEMA_TEXT && payload.text
210
+ ? payload.text
211
+ : JSON.stringify(payload);
212
+ return `- ${ts} ${direction} ${schema} ${truncateLine(summary, 90)}`;
213
+ }
214
+
215
+ function buildTaskExportBody(task, format = 'plain') {
216
+ const payload = {
217
+ task_id: task.task_id,
218
+ title: task.title || null,
219
+ status: task.status,
220
+ updated_at: task.updated_at ?? null,
221
+ updated_at_display: task.updated_at ? formatTs(task.updated_at, false) : null,
222
+ started_at: task.started_at ?? null,
223
+ started_at_display: task.started_at ? formatTs(task.started_at, false) : null,
224
+ result: task.result_summary || null,
225
+ error: task.error_message
226
+ ? {
227
+ code: task.error_code || 'E_TASK',
228
+ message: task.error_message,
229
+ }
230
+ : null,
231
+ handoff: task.handoff || null,
232
+ };
233
+ if (format === 'json') return JSON.stringify(payload, null, 2);
234
+ return [
235
+ `task_id=${payload.task_id}`,
236
+ `title=${payload.title || '(none)'}`,
237
+ `status=${payload.status}`,
238
+ `updated_at=${payload.updated_at_display || '(none)'}`,
239
+ payload.started_at_display ? `started_at=${payload.started_at_display}` : null,
240
+ '',
241
+ '[result]',
242
+ payload.result || '(none)',
243
+ '',
244
+ '[error]',
245
+ payload.error ? `${payload.error.code} ${payload.error.message}` : '(none)',
246
+ ].filter(Boolean).join('\n');
247
+ }
248
+
249
+ function loadHistoryPage(historyManager, conversationId, pageIndex, pageSize) {
250
+ if (!conversationId) return { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
251
+ let page = Math.max(0, pageIndex || 0);
252
+ let messages = historyManager.listRecent(conversationId, pageSize);
253
+ if (messages.length === 0) {
254
+ return { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
255
+ }
256
+ for (let i = 0; i < page; i++) {
257
+ const first = messages[0];
258
+ if (!first) {
259
+ page = i;
260
+ messages = [];
261
+ break;
262
+ }
263
+ const older = first.seq != null
264
+ ? historyManager.listBeforeSeq(conversationId, first.seq, pageSize)
265
+ : historyManager.listBeforeTs(conversationId, first.ts_ms, pageSize);
266
+ if (!older.length) {
267
+ page = i;
268
+ break;
269
+ }
270
+ messages = older;
271
+ }
272
+ const first = messages[0];
273
+ const hasOlder = !!first && (
274
+ first.seq != null
275
+ ? historyManager.listBeforeSeq(conversationId, first.seq, 1).length > 0
276
+ : historyManager.listBeforeTs(conversationId, first.ts_ms, 1).length > 0
277
+ );
278
+ return {
279
+ messages,
280
+ pageIndex: page,
281
+ hasOlder,
282
+ hasNewer: page > 0,
283
+ };
284
+ }
285
+
286
+ function buildHostState(identityPath, selectedSessionKey = null, historyPageIndex = 0, historySearchQuery = '') {
287
+ const identity = loadIdentity(identityPath);
288
+ const store = openStore(identityPath);
289
+ try {
290
+ const sessionManager = new SessionManager(store);
291
+ const sessionSummaryManager = new SessionSummaryManager(store);
292
+ const taskManager = new TaskThreadManager(store);
293
+ const taskHandoffManager = new TaskHandoffManager(store);
294
+ const historyManager = new HistoryManager(store);
295
+ const auditManager = new TrustPolicyAuditManager(store);
296
+ const sessions = sessionManager.listRecentSessions(50);
297
+ const bindings = readSessionBindings();
298
+ const alerts = readSessionBindingAlerts();
299
+ const bindingByConversation = new Map(bindings.map((row) => [row.conversation_id, row]));
300
+ const alertByConversation = new Map(alerts.map((row) => [row.conversation_id, row]));
301
+ const activeChatSession = readCurrentActiveSessionKey();
302
+ const ingressRuntime = readIngressRuntimeStatus();
303
+ const desiredSelectedSessionKey =
304
+ (selectedSessionKey && sessions.some((session) => session.session_key === selectedSessionKey) ? selectedSessionKey : null) ??
305
+ sessionManager.getActiveSession()?.session_key ??
306
+ sessions[0]?.session_key ??
307
+ null;
308
+ const policy = readTrustPolicyDoc(identityPath);
309
+ const runtimeMode = process.env.PINGAGENT_RUNTIME_MODE || 'bridge';
310
+ const sessionsWithMeta = sessions.map((session) => ({
311
+ ...session,
312
+ binding: session.conversation_id ? (bindingByConversation.get(session.conversation_id) ?? null) : null,
313
+ binding_alert: session.conversation_id ? (alertByConversation.get(session.conversation_id) ?? null) : null,
314
+ is_active_chat_session: session.session_key === activeChatSession,
315
+ }));
316
+ const selectedSession = sessionsWithMeta.find((session) => session.session_key === desiredSelectedSessionKey) ?? null;
317
+ const selectedSessionSummary = selectedSession
318
+ ? sessionSummaryManager.get(selectedSession.session_key)
319
+ : null;
320
+ const selectedTasks = selectedSession
321
+ ? taskManager.listBySession(selectedSession.session_key, 12).map((task) => ({
322
+ ...task,
323
+ handoff: taskHandoffManager.get(task.task_id),
324
+ }))
325
+ : [];
326
+ const selectedAuditEvents = selectedSession
327
+ ? auditManager.listBySession(selectedSession.session_key, 12)
328
+ : [];
329
+ const selectedMessages = selectedSession?.conversation_id
330
+ ? historyManager.listRecent(selectedSession.conversation_id, 12)
331
+ : [];
332
+ const selectedHistoryPage = selectedSession?.conversation_id
333
+ ? loadHistoryPage(historyManager, selectedSession.conversation_id, historyPageIndex, 20)
334
+ : { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
335
+ const selectedHistorySearchResults = selectedSession?.conversation_id && historySearchQuery.trim()
336
+ ? historyManager.search(historySearchQuery.trim(), { conversationId: selectedSession.conversation_id, limit: 50 })
337
+ : [];
338
+ const recommendationManager = new TrustRecommendationManager(store);
339
+ recommendationManager.sync({
340
+ policyDoc: policy.doc,
341
+ sessions,
342
+ tasks: taskManager.listRecent(100),
343
+ auditEvents: auditManager.listRecent(200),
344
+ runtimeMode,
345
+ limit: 50,
346
+ });
347
+ const selectedRecommendations = selectedSession?.remote_did
348
+ ? recommendationManager.list({ remoteDid: selectedSession.remote_did, limit: 10 })
349
+ : [];
350
+ const unreadTotal = sessionsWithMeta.reduce((sum, session) => sum + (session.unread_count ?? 0), 0);
351
+ const alertSessions = sessionsWithMeta.filter((session) => !!session.binding_alert).length;
352
+ return {
353
+ identity,
354
+ runtimeMode,
355
+ activeChatSession,
356
+ ingressRuntime,
357
+ activeChatSessionFile: getActiveSessionFilePath(),
358
+ sessionMapPath: getSessionMapFilePath(),
359
+ sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
360
+ policyPath: policy.path,
361
+ policyDoc: policy.doc,
362
+ sessions: sessionsWithMeta,
363
+ tasks: taskManager.listRecent(30).map((task) => ({
364
+ ...task,
365
+ handoff: taskHandoffManager.get(task.task_id),
366
+ })),
367
+ auditEvents: auditManager.listRecent(40),
368
+ selectedSession,
369
+ selectedSessionSummary,
370
+ selectedTasks,
371
+ selectedAuditEvents,
372
+ selectedMessages,
373
+ selectedHistoryPage,
374
+ selectedHistorySearchQuery: historySearchQuery.trim(),
375
+ selectedHistorySearchResults,
376
+ selectedRecommendations,
377
+ unreadTotal,
378
+ alertSessions,
379
+ };
380
+ } finally {
381
+ store.close();
382
+ }
383
+ }
384
+
385
+ function renderHostTuiScreen(hostState, uiState) {
386
+ const sessions = hostState.sessions || [];
387
+ const selected = hostState.selectedSession || sessions[0] || null;
388
+ const selectedSummary = hostState.selectedSessionSummary || null;
389
+ const tasks = hostState.selectedTasks || [];
390
+ const auditEvents = hostState.selectedAuditEvents || [];
391
+ const messages = hostState.selectedMessages || [];
392
+ const recommendations = hostState.selectedRecommendations || [];
393
+ const openRecommendation = recommendations.find((item) => item.status === 'open') || null;
394
+ const reopenRecommendation = recommendations.find((item) => item.status === 'dismissed' || item.status === 'superseded') || null;
395
+ const historyPage = hostState.selectedHistoryPage || { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
396
+ const historySearchQuery = hostState.selectedHistorySearchQuery || '';
397
+ const historySearchResults = hostState.selectedHistorySearchResults || [];
398
+ const view = uiState?.view || 'overview';
399
+ const selectedTaskIndex = Math.max(0, Math.min(uiState?.selectedTaskIndex || 0, Math.max(0, tasks.length - 1)));
400
+ const selectedTask = tasks[selectedTaskIndex] || null;
401
+ const statusCountdown = uiState?.statusExpiresAt
402
+ ? ` (${Math.max(0, Math.ceil((uiState.statusExpiresAt - Date.now()) / 1000))}s)`
403
+ : '';
404
+ const statusTs = uiState?.statusAt ? ` @ ${formatStatusTimestamp(uiState.statusAt)}` : '';
405
+ const degraded = !hostState.ingressRuntime
406
+ || hostState.ingressRuntime.receive_mode === 'polling_degraded'
407
+ || !!hostState.ingressRuntime.hooks_last_error;
408
+ const ingressLabel = degraded ? 'Degraded' : 'Ready';
409
+ const lines = [
410
+ 'PingAgent Host TUI',
411
+ `DID: ${hostState.identity.did}`,
412
+ `status=${formatStatusLine(uiState?.statusLevel || 'info', uiState?.statusMessage || '(ready)')}${statusTs}${statusCountdown}`,
413
+ `runtime_mode=${hostState.runtimeMode} receive_mode=${hostState.ingressRuntime?.receive_mode || 'webhook'} active_chat_session=${hostState.activeChatSession || '(none)'}`,
414
+ `ingress=${ingressLabel}${degraded ? ' action=[f] fix-now' : ''}`,
415
+ uiState?.publicLinkUrl ? `public_link=${uiState.publicLinkUrl}` : null,
416
+ `sessions=${sessions.length} unread_total=${hostState.unreadTotal ?? 0} alert_sessions=${hostState.alertSessions ?? 0} view=${view}`,
417
+ `policy=${hostState.policyPath}`,
418
+ `session_map=${hostState.sessionMapPath}`,
419
+ `binding_alerts=${hostState.sessionBindingAlertsPath}`,
420
+ hostState.ingressRuntime?.hooks_last_error ? `hooks_error=${truncateLine(hostState.ingressRuntime.hooks_last_error, 120)}` : null,
421
+ '',
422
+ ].filter(Boolean);
423
+
424
+ if (view === 'help') {
425
+ lines.push('Help');
426
+ lines.push('- ↑/↓ or j/k: move selection');
427
+ lines.push('- Enter or l: open detailed session view / task detail');
428
+ lines.push('- Esc or h: back to previous view');
429
+ lines.push('- g / G: jump to first / last session');
430
+ lines.push('- r: refresh now');
431
+ lines.push('- a: approve selected pending contact');
432
+ lines.push('- m: mark selected session as read');
433
+ lines.push('- t: open task list view for selected session');
434
+ lines.push('- x: cancel selected task (in task views)');
435
+ lines.push('- p: multiline reply prompt (detail view)');
436
+ lines.push('- S: edit carry-forward summary (detail view)');
437
+ lines.push('- d: try demo agent preset');
438
+ lines.push('- o: open local history paging (detail view)');
439
+ lines.push('- n / p: older / newer history page (history view)');
440
+ lines.push('- s or /: search local history (history view)');
441
+ lines.push('- y: dump task detail to stdout (task-detail view, choose json/plain)');
442
+ lines.push('- f: repair OpenClaw hooks config');
443
+ lines.push('- A: apply first open trust recommendation for selected session');
444
+ lines.push('- D: dismiss current open recommendation');
445
+ lines.push('- R: reopen dismissed/superseded recommendation');
446
+ lines.push('- b: bind selected conversation to current chat session');
447
+ lines.push('- c: clear selected binding');
448
+ lines.push('- q: quit');
449
+ } else if (view === 'history') {
450
+ lines.push('Conversation History');
451
+ if (!selected) {
452
+ lines.push('No session selected.');
453
+ } else {
454
+ lines.push(`session=${selected.session_key}`);
455
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
456
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
457
+ if (historySearchQuery) {
458
+ lines.push(`search=${historySearchQuery} results=${historySearchResults.length}`);
459
+ lines.push('actions=[s or /] search [n] clear-search [h/Esc] back');
460
+ } else {
461
+ lines.push(`page=${historyPage.pageIndex + 1} has_newer=${historyPage.hasNewer} has_older=${historyPage.hasOlder}`);
462
+ lines.push('actions=[s or /] search [n] older [p] newer [h/Esc] back');
463
+ }
464
+ lines.push('');
465
+ lines.push(...((historySearchQuery ? historySearchResults : historyPage.messages).length
466
+ ? (historySearchQuery ? historySearchResults : historyPage.messages).map((message) => formatMessageRow(message))
467
+ : ['- none']));
468
+ }
469
+ } else if (view === 'task-detail') {
470
+ lines.push('Task Result Detail');
471
+ if (!selected) {
472
+ lines.push('No session selected.');
473
+ } else if (!selectedTask) {
474
+ lines.push('No task selected.');
475
+ } else {
476
+ lines.push(`session=${selected.session_key}`);
477
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
478
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
479
+ lines.push('');
480
+ lines.push(`task=${selectedTask.task_id}`);
481
+ lines.push(`title=${selectedTask.title || '(none)'}`);
482
+ lines.push(`status=${selectedTask.status}`);
483
+ lines.push(`updated_at=${formatTs(selectedTask.updated_at, false)}`);
484
+ if (selectedTask.started_at) lines.push(`started_at=${formatTs(selectedTask.started_at, false)}`);
485
+ if (selectedTask.handoff?.objective) lines.push(`handoff_objective=${selectedTask.handoff.objective}`);
486
+ if (selectedTask.handoff?.priority) lines.push(`handoff_priority=${selectedTask.handoff.priority}`);
487
+ if (selectedTask.handoff?.success_criteria) lines.push(`handoff_success=${selectedTask.handoff.success_criteria}`);
488
+ if (selectedTask.handoff?.callback_session_key) lines.push(`handoff_callback=${selectedTask.handoff.callback_session_key}`);
489
+ lines.push('actions=[x] cancel-task [y] dump-stdout [j/k] switch-task [h/Esc] back-to-tasks');
490
+ lines.push('');
491
+ if (selectedTask.handoff?.carry_forward_summary) {
492
+ lines.push('Handoff Summary');
493
+ lines.push(selectedTask.handoff.carry_forward_summary);
494
+ lines.push('');
495
+ }
496
+ lines.push('Result');
497
+ lines.push(selectedTask.result_summary || '(none)');
498
+ lines.push('');
499
+ lines.push('Error');
500
+ lines.push(selectedTask.error_message ? `${selectedTask.error_code || 'E_TASK'} ${selectedTask.error_message}` : '(none)');
501
+ lines.push('');
502
+ lines.push('Tasks In Session');
503
+ lines.push(...tasks.map((task, idx) => `${idx === selectedTaskIndex ? '>' : ' '} ${task.task_id} [${task.status}] ${truncateLine(task.title || task.result_summary || '', 70)}`));
504
+ }
505
+ } else if (view === 'tasks') {
506
+ lines.push('Task Detail');
507
+ if (!selected) {
508
+ lines.push('No session selected.');
509
+ } else if (!selectedTask) {
510
+ lines.push('No tasks in the selected session.');
511
+ } else {
512
+ lines.push(`session=${selected.session_key}`);
513
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
514
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
515
+ lines.push('');
516
+ lines.push(`task=${selectedTask.task_id}`);
517
+ lines.push(`title=${selectedTask.title || '(none)'}`);
518
+ lines.push(`status=${selectedTask.status}`);
519
+ lines.push(`updated_at=${formatTs(selectedTask.updated_at, false)}`);
520
+ if (selectedTask.started_at) lines.push(`started_at=${formatTs(selectedTask.started_at, false)}`);
521
+ if (selectedTask.handoff?.objective) lines.push(`handoff_objective=${selectedTask.handoff.objective}`);
522
+ if (selectedTask.handoff?.priority) lines.push(`handoff_priority=${selectedTask.handoff.priority}`);
523
+ if (selectedTask.result_summary) lines.push(`result=${selectedTask.result_summary}`);
524
+ if (selectedTask.error_code || selectedTask.error_message) {
525
+ lines.push(`error=${selectedTask.error_code || 'E_TASK'} ${selectedTask.error_message || ''}`.trim());
526
+ }
527
+ lines.push(`actions=[Enter/l] full-detail [x] cancel-task [h/Esc] back [j/k] select-task`);
528
+ lines.push('');
529
+ lines.push('Tasks In Session');
530
+ lines.push(...tasks.map((task, idx) => `${idx === selectedTaskIndex ? '>' : ' '} ${task.task_id} [${task.status}] ${truncateLine(task.title || task.result_summary || '', 70)}`));
531
+ }
532
+ } else {
533
+ lines.push('Sessions');
534
+ lines.push(...(sessions.length
535
+ ? sessions.slice(0, 20).map((session) => formatSessionRow(session, session.session_key === (selected?.session_key || '')))
536
+ : [' (no sessions)']));
537
+ lines.push('');
538
+
539
+ if (selected) {
540
+ lines.push(view === 'detail' ? 'Selected Session Detail' : 'Selected Session');
541
+ lines.push(`session=${selected.session_key}`);
542
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
543
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
544
+ lines.push(`trust=${selected.trust_state} unread=${selected.unread_count}`);
545
+ lines.push(`last_preview=${selected.last_message_preview || '(none)'}`);
546
+ lines.push(`binding=${selected.binding?.session_key || '(unbound)'}`);
547
+ lines.push(`current_chat=${hostState.activeChatSession || '(none)'}`);
548
+ if (selected.binding_alert) {
549
+ lines.push(`needs_rebind=true`);
550
+ lines.push(`warning=${selected.binding_alert.message}`);
551
+ } else {
552
+ lines.push('needs_rebind=false');
553
+ }
554
+ if (openRecommendation) {
555
+ lines.push(`trust_action=${getTrustRecommendationActionLabel(openRecommendation)}`);
556
+ } else if (reopenRecommendation) {
557
+ lines.push(`trust_action=${getTrustRecommendationActionLabel(reopenRecommendation)}`);
558
+ }
559
+ if (selectedSummary) {
560
+ lines.push(`summary_objective=${selectedSummary.objective || '(none)'}`);
561
+ lines.push(`summary_next_action=${selectedSummary.next_action || '(none)'}`);
562
+ } else {
563
+ lines.push('summary_objective=(none)');
564
+ }
565
+ const actionBar = [
566
+ selected.trust_state === 'pending' ? '[a] approve' : null,
567
+ '[A] apply-rec',
568
+ '[D] dismiss-rec',
569
+ '[R] reopen-rec',
570
+ '[m] mark-read',
571
+ '[d] demo',
572
+ '[p] reply',
573
+ '[S] summary',
574
+ '[o] history',
575
+ '[t] tasks',
576
+ '[b] bind-current',
577
+ '[c] clear-binding',
578
+ ].filter(Boolean).join(' ');
579
+ lines.push(`actions=${actionBar}`);
580
+ lines.push('');
581
+ lines.push('Carry-Forward Summary');
582
+ if (selectedSummary) {
583
+ lines.push(`- objective: ${selectedSummary.objective || '(none)'}`);
584
+ lines.push(`- context: ${truncateLine(selectedSummary.context || '(none)', 100)}`);
585
+ lines.push(`- constraints: ${truncateLine(selectedSummary.constraints || '(none)', 100)}`);
586
+ lines.push(`- decisions: ${truncateLine(selectedSummary.decisions || '(none)', 100)}`);
587
+ lines.push(`- open_questions: ${truncateLine(selectedSummary.open_questions || '(none)', 100)}`);
588
+ lines.push(`- next_action: ${truncateLine(selectedSummary.next_action || '(none)', 100)}`);
589
+ lines.push(`- handoff_ready: ${truncateLine(selectedSummary.handoff_ready_text || '(none)', 100)}`);
590
+ } else {
591
+ lines.push('- none');
592
+ }
593
+ lines.push('');
594
+ lines.push('Tasks');
595
+ lines.push(...(tasks.length
596
+ ? tasks.map((task) => `- ${task.title || task.task_id} [${task.status}]${task.handoff?.objective ? ` handoff=${truncateLine(task.handoff.objective, 30)}` : ''} ${truncateLine(task.result_summary || task.error_message || '', 80)}`)
597
+ : ['- none']));
598
+ if (view === 'detail') {
599
+ lines.push('');
600
+ lines.push('Recent Messages');
601
+ lines.push(...(messages.length ? messages.map((message) => formatMessageRow(message)) : ['- none']));
602
+ }
603
+ lines.push('');
604
+ lines.push('Audit');
605
+ lines.push(...(auditEvents.length
606
+ ? auditEvents.map((event) => `- ${event.event_type} ${event.action || event.outcome || ''} ${truncateLine(event.explanation || '', 80)}`)
607
+ : ['- none']));
608
+ } else {
609
+ lines.push('No session selected.');
610
+ }
611
+ }
612
+
613
+ lines.push('');
614
+ lines.push('Keys: ↑/↓ or j/k select Enter/l open Esc/h back g/G jump r refresh a approve A apply-rec D dismiss-rec R reopen-rec d demo m read p reply o history s search t tasks x cancel-task y dump f fix-hooks b bind c clear ? help q quit');
615
+ return lines.join('\n');
616
+ }
617
+
618
+ async function runHostTui(identityPath, opts) {
619
+ const once = !!opts.once;
620
+ const refreshMs = Math.max(500, Number.parseInt(String(opts.refreshMs || '2000'), 10) || 2000);
621
+
622
+ const uiState = {
623
+ selectedSessionKey: null,
624
+ view: 'overview',
625
+ selectedTaskIndex: 0,
626
+ statusMessage: '(ready)',
627
+ statusLevel: 'info',
628
+ statusExpiresAt: 0,
629
+ statusAt: 0,
630
+ selectedHistoryPageIndex: 0,
631
+ historySearchQuery: '',
632
+ publicLinkUrl: '',
633
+ };
634
+
635
+ const render = () => {
636
+ if (uiState.statusExpiresAt && Date.now() >= uiState.statusExpiresAt) {
637
+ uiState.statusMessage = '(ready)';
638
+ uiState.statusLevel = 'info';
639
+ uiState.statusExpiresAt = 0;
640
+ uiState.statusAt = 0;
641
+ }
642
+ const hostState = buildHostState(
643
+ identityPath,
644
+ uiState.selectedSessionKey,
645
+ uiState.selectedHistoryPageIndex,
646
+ uiState.historySearchQuery,
647
+ );
648
+ uiState.selectedSessionKey = hostState.selectedSession?.session_key || hostState.sessions[0]?.session_key || null;
649
+ uiState.selectedHistoryPageIndex = hostState.selectedHistoryPage?.pageIndex || 0;
650
+ const screen = renderHostTuiScreen(hostState, uiState);
651
+ return { hostState, screen };
652
+ };
653
+
654
+ if (once) {
655
+ void maybeEnsureHostedPublicLink(identityPath).then((res) => {
656
+ if (res?.data?.public_url) uiState.publicLinkUrl = res.data.public_url;
657
+ });
658
+ const { screen } = render();
659
+ console.log(screen);
660
+ return;
661
+ }
662
+
663
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
664
+ console.error('Host TUI requires a TTY. Use --once for a snapshot in non-interactive environments.');
665
+ process.exit(1);
666
+ }
667
+
668
+ let interval = null;
669
+ const redraw = () => {
670
+ const rendered = render();
671
+ clearScreen();
672
+ process.stdout.write(`${rendered.screen}\n`);
673
+ return rendered.hostState;
674
+ };
675
+
676
+ let latestState = redraw();
677
+ readline.emitKeypressEvents(process.stdin);
678
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
679
+
680
+ const cleanup = () => {
681
+ if (interval) clearInterval(interval);
682
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
683
+ process.stdin.pause();
684
+ };
685
+
686
+ const setStatus = (message, level = 'info', ttlMs = 5000) => {
687
+ uiState.statusMessage = truncateLine(message || '(ready)', 160);
688
+ uiState.statusLevel = level;
689
+ uiState.statusExpiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
690
+ uiState.statusAt = Date.now();
691
+ };
692
+
693
+ const applySessionRecommendation = (selected) => {
694
+ if (!selected?.remote_did) return { ok: false, message: 'No remote DID for selected session.' };
695
+ const store = openStore(identityPath);
696
+ try {
697
+ const { path: policyPath, doc } = readTrustPolicyDoc(identityPath);
698
+ const auditManager = new TrustPolicyAuditManager(store);
699
+ const recommendationManager = new TrustRecommendationManager(store);
700
+ recommendationManager.sync({
701
+ policyDoc: doc,
702
+ sessions: new SessionManager(store).listRecentSessions(100),
703
+ tasks: new TaskThreadManager(store).listRecent(100),
704
+ auditEvents: auditManager.listRecent(200),
705
+ runtimeMode: process.env.PINGAGENT_RUNTIME_MODE || 'bridge',
706
+ limit: 50,
707
+ });
708
+ const recommendation = recommendationManager.list({
709
+ remoteDid: selected.remote_did,
710
+ status: 'open',
711
+ limit: 1,
712
+ })[0];
713
+ if (!recommendation) return { ok: false, message: 'No open recommendation for this session.' };
714
+ const nextDoc = upsertTrustPolicyRecommendation(doc, recommendation);
715
+ writeTrustPolicyDoc(identityPath, nextDoc);
716
+ recommendationManager.apply(recommendation.id);
717
+ auditManager.record({
718
+ event_type: 'recommendation_applied',
719
+ policy_scope: recommendation.policy,
720
+ remote_did: recommendation.remote_did,
721
+ action: String(recommendation.action),
722
+ outcome: 'recommendation_applied',
723
+ explanation: recommendation.reason,
724
+ matched_rule: recommendation.match,
725
+ detail: { recommendation_id: recommendation.id, session_key: selected.session_key },
726
+ });
727
+ return { ok: true, message: `${getTrustRecommendationActionLabel(recommendation)} (${recommendation.policy})`, path: policyPath };
728
+ } finally {
729
+ store.close();
730
+ }
731
+ };
732
+
733
+ const dismissSessionRecommendation = (selected) => {
734
+ if (!selected?.remote_did) return { ok: false, message: 'No remote DID for selected session.' };
735
+ const store = openStore(identityPath);
736
+ try {
737
+ const recommendationManager = new TrustRecommendationManager(store);
738
+ const recommendation = recommendationManager.list({
739
+ remoteDid: selected.remote_did,
740
+ status: 'open',
741
+ limit: 1,
742
+ })[0];
743
+ if (!recommendation) return { ok: false, message: 'No open recommendation for this session.' };
744
+ recommendationManager.dismiss(recommendation.id);
745
+ new TrustPolicyAuditManager(store).record({
746
+ event_type: 'recommendation_dismissed',
747
+ policy_scope: recommendation.policy,
748
+ remote_did: recommendation.remote_did,
749
+ action: String(recommendation.action),
750
+ outcome: 'recommendation_dismissed',
751
+ explanation: recommendation.reason,
752
+ matched_rule: recommendation.match,
753
+ detail: { recommendation_id: recommendation.id, session_key: selected.session_key },
754
+ });
755
+ return { ok: true, message: `Dismissed ${getTrustRecommendationActionLabel(recommendation)}` };
756
+ } finally {
757
+ store.close();
758
+ }
759
+ };
760
+
761
+ const reopenSessionRecommendation = (selected) => {
762
+ if (!selected?.remote_did) return { ok: false, message: 'No remote DID for selected session.' };
763
+ const store = openStore(identityPath);
764
+ try {
765
+ const recommendationManager = new TrustRecommendationManager(store);
766
+ const recommendation = recommendationManager.list({
767
+ remoteDid: selected.remote_did,
768
+ status: ['dismissed', 'superseded'],
769
+ limit: 1,
770
+ })[0];
771
+ if (!recommendation) return { ok: false, message: 'No dismissed or superseded recommendation for this session.' };
772
+ recommendationManager.reopen(recommendation.id);
773
+ new TrustPolicyAuditManager(store).record({
774
+ event_type: 'recommendation_reopened',
775
+ policy_scope: recommendation.policy,
776
+ remote_did: recommendation.remote_did,
777
+ action: String(recommendation.action),
778
+ outcome: 'recommendation_reopened',
779
+ explanation: recommendation.reason,
780
+ matched_rule: recommendation.match,
781
+ detail: { recommendation_id: recommendation.id, session_key: selected.session_key },
782
+ });
783
+ return { ok: true, message: 'Reopened recommendation' };
784
+ } finally {
785
+ store.close();
786
+ }
787
+ };
788
+
789
+ const sendDemoPreset = async () => {
790
+ const answer = await promptLine('Demo preset [hello/delegate/trust] (default hello): ');
791
+ const preset = (answer.trim().toLowerCase() || 'hello');
792
+ const presetMessages = {
793
+ hello: 'Hello',
794
+ delegate: 'Please show me how task delegation works in PingAgent.',
795
+ trust: 'Show me how trust decisions and recommendations work.',
796
+ };
797
+ const message = presetMessages[preset] || presetMessages.hello;
798
+ const { client, store } = await getClientWithStore(identityPath);
799
+ try {
800
+ const resolved = await client.resolveAlias('pingagent/demo');
801
+ if (!resolved.ok || !resolved.data?.did) {
802
+ setStatus(`Demo resolve failed: ${resolved.error?.message || 'unknown error'}`, 'err');
803
+ return;
804
+ }
805
+ const convo = await client.openConversation(resolved.data.did);
806
+ if (!convo.ok || !convo.data?.conversation_id) {
807
+ setStatus(`Demo open failed: ${convo.error?.message || 'unknown error'}`, 'err');
808
+ return;
809
+ }
810
+ const sendRes = await client.sendMessage(convo.data.conversation_id, SCHEMA_TEXT, { text: message });
811
+ setStatus(sendRes.ok
812
+ ? `Demo preset sent (${preset}) conversation=${convo.data.conversation_id}`
813
+ : `Demo send failed: ${sendRes.error?.message || 'unknown error'}`, sendRes.ok ? 'ok' : 'err', sendRes.ok ? 7000 : 9000);
814
+ } finally {
815
+ store.close();
816
+ }
817
+ };
818
+
819
+ const promptLine = async (question) => {
820
+ stopInterval();
821
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
822
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
823
+ try {
824
+ const answer = await new Promise((resolve) => {
825
+ rl.question(question, resolve);
826
+ });
827
+ return String(answer ?? '');
828
+ } finally {
829
+ rl.close();
830
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
831
+ startInterval();
832
+ }
833
+ };
834
+
835
+ const promptMultiline = async (question) => {
836
+ stopInterval();
837
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
838
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
839
+ const lines = [];
840
+ try {
841
+ process.stdout.write(`${question}\nFinish with a single "." on its own line.\n`);
842
+ while (true) {
843
+ // eslint-disable-next-line no-await-in-loop
844
+ const line = await new Promise((resolve) => rl.question('> ', resolve));
845
+ const text = String(line ?? '');
846
+ if (text === '.') break;
847
+ lines.push(text);
848
+ }
849
+ return lines.join('\n').trim();
850
+ } finally {
851
+ rl.close();
852
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
853
+ startInterval();
854
+ }
855
+ };
856
+
857
+ const dumpStdoutBlock = async (title, body) => {
858
+ stopInterval();
859
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
860
+ try {
861
+ process.stdout.write(`\n===== ${title} =====\n${body}\n===== end =====\nPress Enter to return...`);
862
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
863
+ await new Promise((resolve) => rl.question('', resolve));
864
+ rl.close();
865
+ } finally {
866
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
867
+ startInterval();
868
+ }
869
+ };
870
+
871
+ const confirmAction = async (question) => {
872
+ const answer = await promptLine(`${question} [y/N] `);
873
+ return answer.trim().toLowerCase() === 'y';
874
+ };
875
+
876
+ const moveSelection = (delta) => {
877
+ if (uiState.view === 'tasks' || uiState.view === 'task-detail') {
878
+ const tasks = latestState.selectedTasks || [];
879
+ if (!tasks.length) return;
880
+ uiState.selectedTaskIndex = Math.min(tasks.length - 1, Math.max(0, uiState.selectedTaskIndex + delta));
881
+ } else if (uiState.view === 'history' && !uiState.historySearchQuery) {
882
+ if (delta > 0 && latestState.selectedHistoryPage?.hasOlder) uiState.selectedHistoryPageIndex += 1;
883
+ if (delta < 0 && latestState.selectedHistoryPage?.hasNewer) uiState.selectedHistoryPageIndex = Math.max(0, uiState.selectedHistoryPageIndex - 1);
884
+ } else {
885
+ const sessions = latestState.sessions || [];
886
+ if (!sessions.length) return;
887
+ const idx = Math.max(0, sessions.findIndex((session) => session.session_key === uiState.selectedSessionKey));
888
+ const next = Math.min(sessions.length - 1, Math.max(0, idx + delta));
889
+ uiState.selectedSessionKey = sessions[next].session_key;
890
+ uiState.selectedTaskIndex = 0;
891
+ uiState.selectedHistoryPageIndex = 0;
892
+ uiState.historySearchQuery = '';
893
+ }
894
+ latestState = redraw();
895
+ };
896
+
897
+ const jumpSelection = (target) => {
898
+ if (uiState.view === 'tasks' || uiState.view === 'task-detail') {
899
+ const tasks = latestState.selectedTasks || [];
900
+ if (!tasks.length) return;
901
+ uiState.selectedTaskIndex = target;
902
+ } else if (uiState.view === 'history') {
903
+ uiState.selectedHistoryPageIndex = 0;
904
+ } else {
905
+ const sessions = latestState.sessions || [];
906
+ if (!sessions.length) return;
907
+ uiState.selectedSessionKey = sessions[target].session_key;
908
+ uiState.selectedTaskIndex = 0;
909
+ uiState.selectedHistoryPageIndex = 0;
910
+ uiState.historySearchQuery = '';
911
+ }
912
+ latestState = redraw();
913
+ };
914
+
915
+ const stopInterval = () => {
916
+ if (interval) clearInterval(interval);
917
+ interval = null;
918
+ };
919
+
920
+ const startInterval = () => {
921
+ stopInterval();
922
+ interval = setInterval(() => {
923
+ latestState = redraw();
924
+ }, refreshMs);
925
+ };
926
+
927
+ startInterval();
928
+ void maybeEnsureHostedPublicLink(identityPath).then((result) => {
929
+ if (result?.data?.public_url) {
930
+ uiState.publicLinkUrl = result.data.public_url;
931
+ setStatus(result.created ? `Public link ready: ${result.data.public_url}` : `Public link available: ${result.data.public_url}`, 'ok', 7000);
932
+ latestState = redraw();
933
+ }
934
+ });
935
+
936
+ process.stdin.on('keypress', async (_str, key) => {
937
+ if (key?.name === 'q' || (key?.ctrl && key?.name === 'c')) {
938
+ cleanup();
939
+ process.stdout.write('\n');
940
+ process.exit(0);
941
+ }
942
+ if (key?.name === 'up' || key?.name === 'k') return moveSelection(-1);
943
+ if (key?.name === 'down' || key?.name === 'j') return moveSelection(1);
944
+ if (key?.name === 'g') return jumpSelection(0);
945
+ if (_str === 'G') {
946
+ const max = uiState.view === 'tasks' || uiState.view === 'task-detail'
947
+ ? Math.max(0, (latestState.selectedTasks || []).length - 1)
948
+ : Math.max(0, (latestState.sessions || []).length - 1);
949
+ return jumpSelection(max);
950
+ }
951
+ if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'l') {
952
+ if (uiState.view === 'tasks') {
953
+ uiState.view = 'task-detail';
954
+ } else {
955
+ uiState.view = 'detail';
956
+ }
957
+ latestState = redraw();
958
+ return;
959
+ }
960
+ if (key?.name === 'escape' || key?.name === 'h') {
961
+ if (uiState.view === 'task-detail') uiState.view = 'tasks';
962
+ else if (uiState.view === 'history') uiState.view = 'detail';
963
+ else uiState.view = 'overview';
964
+ latestState = redraw();
965
+ return;
966
+ }
967
+ if (_str === '?') {
968
+ uiState.view = uiState.view === 'help' ? 'overview' : 'help';
969
+ latestState = redraw();
970
+ return;
971
+ }
972
+ if (_str === 'n' && uiState.view === 'history') {
973
+ if (uiState.historySearchQuery) {
974
+ uiState.historySearchQuery = '';
975
+ setStatus('Cleared history search.', 'info');
976
+ latestState = redraw();
977
+ return;
978
+ }
979
+ if (latestState.selectedHistoryPage?.hasOlder) {
980
+ uiState.selectedHistoryPageIndex += 1;
981
+ setStatus(`History page ${uiState.selectedHistoryPageIndex + 1}`, 'info');
982
+ latestState = redraw();
983
+ }
984
+ return;
985
+ }
986
+ if (_str === 'p' && uiState.view === 'history') {
987
+ if (uiState.historySearchQuery) return;
988
+ if (latestState.selectedHistoryPage?.hasNewer) {
989
+ uiState.selectedHistoryPageIndex = Math.max(0, uiState.selectedHistoryPageIndex - 1);
990
+ setStatus(`History page ${uiState.selectedHistoryPageIndex + 1}`, 'info');
991
+ latestState = redraw();
992
+ }
993
+ return;
994
+ }
995
+ if (key?.name === 't') {
996
+ uiState.view = 'tasks';
997
+ uiState.selectedTaskIndex = 0;
998
+ uiState.selectedHistoryPageIndex = 0;
999
+ setStatus('Opened task list view.', 'info');
1000
+ latestState = redraw();
1001
+ return;
1002
+ }
1003
+ if (key?.name === 'r') {
1004
+ setStatus('Refreshed.', 'info');
1005
+ latestState = redraw();
1006
+ return;
1007
+ }
1008
+ if (key?.name === 'm') {
1009
+ const selected = latestState.selectedSession;
1010
+ if (!selected) return;
1011
+ const store = openStore(identityPath);
1012
+ try {
1013
+ const sessions = new SessionManager(store);
1014
+ sessions.markRead(selected.session_key);
1015
+ setStatus(`Marked session as read: ${selected.session_key}`, 'ok');
1016
+ } finally {
1017
+ store.close();
1018
+ }
1019
+ latestState = redraw();
1020
+ return;
1021
+ }
1022
+ if (key?.name === 'a') {
1023
+ const selected = latestState.selectedSession;
1024
+ if (!selected?.conversation_id || selected.trust_state !== 'pending') return;
1025
+ const confirmed = await confirmAction(`Approve pending conversation ${selected.conversation_id}\nRemote DID: ${selected.remote_did || '(unknown)'}\nProceed?`);
1026
+ if (confirmed) {
1027
+ const { client, store } = await getClientWithStore(identityPath);
1028
+ try {
1029
+ const res = await client.approveContact(selected.conversation_id);
1030
+ setStatus(res.ok
1031
+ ? `Approved conversation ${selected.conversation_id}${res.data?.dm_conversation_id ? ` -> ${res.data.dm_conversation_id}` : ''}`
1032
+ : `Approve failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
1033
+ } finally {
1034
+ store.close();
1035
+ }
1036
+ } else {
1037
+ setStatus('Approve cancelled.', 'warn');
1038
+ }
1039
+ latestState = redraw();
1040
+ return;
1041
+ }
1042
+ if (_str === 'A') {
1043
+ const selected = latestState.selectedSession;
1044
+ if (!selected) return;
1045
+ const result = applySessionRecommendation(selected);
1046
+ setStatus(result.message, result.ok ? 'ok' : 'warn');
1047
+ latestState = redraw();
1048
+ return;
1049
+ }
1050
+ if (_str === 'D') {
1051
+ const selected = latestState.selectedSession;
1052
+ if (!selected) return;
1053
+ const result = dismissSessionRecommendation(selected);
1054
+ setStatus(result.message, result.ok ? 'ok' : 'warn');
1055
+ latestState = redraw();
1056
+ return;
1057
+ }
1058
+ if (_str === 'R') {
1059
+ const selected = latestState.selectedSession;
1060
+ if (!selected) return;
1061
+ const result = reopenSessionRecommendation(selected);
1062
+ setStatus(result.message, result.ok ? 'ok' : 'warn');
1063
+ latestState = redraw();
1064
+ return;
1065
+ }
1066
+ if (key?.name === 'd') {
1067
+ await sendDemoPreset();
1068
+ latestState = redraw();
1069
+ return;
1070
+ }
1071
+ if (key?.name === 'p' && uiState.view === 'detail') {
1072
+ const selected = latestState.selectedSession;
1073
+ if (!selected?.conversation_id) return;
1074
+ const input = await promptMultiline(`Reply to ${selected.remote_did || selected.conversation_id}`);
1075
+ const message = input.trim();
1076
+ if (!message) {
1077
+ setStatus('Reply cancelled.', 'warn');
1078
+ latestState = redraw();
1079
+ return;
1080
+ }
1081
+ const { client, store } = await getClientWithStore(identityPath);
1082
+ try {
1083
+ const res = await client.sendMessage(selected.conversation_id, SCHEMA_TEXT, { text: message });
1084
+ setStatus(res.ok
1085
+ ? `Sent reply to ${selected.remote_did || selected.conversation_id} (${res.data?.message_id || 'queued'})`
1086
+ : `Reply failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
1087
+ } finally {
1088
+ store.close();
1089
+ }
1090
+ latestState = redraw();
1091
+ return;
1092
+ }
1093
+ if (_str === 'S' && uiState.view === 'detail') {
1094
+ const selected = latestState.selectedSession;
1095
+ if (!selected) return;
1096
+ const existing = latestState.selectedSessionSummary || {};
1097
+ const input = await promptMultiline(
1098
+ `Edit carry-forward summary as JSON for ${selected.remote_did || selected.session_key}\nCurrent:\n${JSON.stringify({
1099
+ objective: existing.objective || '',
1100
+ context: existing.context || '',
1101
+ constraints: existing.constraints || '',
1102
+ decisions: existing.decisions || '',
1103
+ open_questions: existing.open_questions || '',
1104
+ next_action: existing.next_action || '',
1105
+ handoff_ready_text: existing.handoff_ready_text || '',
1106
+ }, null, 2)}`
1107
+ );
1108
+ if (!input.trim()) {
1109
+ setStatus('Summary update cancelled.', 'warn');
1110
+ latestState = redraw();
1111
+ return;
1112
+ }
1113
+ try {
1114
+ const parsed = JSON.parse(input);
1115
+ const store = openStore(identityPath);
1116
+ try {
1117
+ const manager = new SessionSummaryManager(store);
1118
+ manager.upsert({
1119
+ session_key: selected.session_key,
1120
+ objective: parsed.objective,
1121
+ context: parsed.context,
1122
+ constraints: parsed.constraints,
1123
+ decisions: parsed.decisions,
1124
+ open_questions: parsed.open_questions,
1125
+ next_action: parsed.next_action,
1126
+ handoff_ready_text: parsed.handoff_ready_text,
1127
+ });
1128
+ } finally {
1129
+ store.close();
1130
+ }
1131
+ setStatus(`Saved carry-forward summary for ${selected.session_key}`, 'ok');
1132
+ } catch (error) {
1133
+ setStatus(`Summary update failed: ${error?.message || 'invalid JSON'}`, 'err', 9000);
1134
+ }
1135
+ latestState = redraw();
1136
+ return;
1137
+ }
1138
+ if (key?.name === 'o' && uiState.view === 'detail') {
1139
+ uiState.view = 'history';
1140
+ uiState.selectedHistoryPageIndex = 0;
1141
+ uiState.historySearchQuery = '';
1142
+ setStatus('Opened local history view.', 'info');
1143
+ latestState = redraw();
1144
+ return;
1145
+ }
1146
+ if ((_str === '/' || key?.name === 's') && uiState.view === 'history') {
1147
+ const answer = await promptLine(`Search history (${latestState.selectedSession?.conversation_id || 'current conversation'}). Empty clears search: `);
1148
+ const query = answer.trim();
1149
+ uiState.historySearchQuery = query;
1150
+ uiState.selectedHistoryPageIndex = 0;
1151
+ setStatus(query ? `History search: ${query}` : 'Cleared history search.', 'info');
1152
+ latestState = redraw();
1153
+ return;
1154
+ }
1155
+ if (key?.name === 'b') {
1156
+ const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
1157
+ if (!selected?.conversation_id) return;
1158
+ const current = latestState.activeChatSession || '(none)';
1159
+ const previous = selected.binding?.session_key || '(unbound)';
1160
+ const confirmed = await confirmAction(`Rebind conversation ${selected.conversation_id}\nRemote DID: ${selected.remote_did || '(unknown)'}\nCurrent chat: ${current}\nPrevious binding: ${previous}\nProceed?`);
1161
+ if (confirmed) {
1162
+ if (!latestState.activeChatSession) {
1163
+ setStatus('Rebind failed: no active chat session.', 'err');
1164
+ latestState = redraw();
1165
+ return;
1166
+ }
1167
+ setSessionBinding(selected.conversation_id, latestState.activeChatSession);
1168
+ setStatus(`Rebound ${selected.conversation_id} -> ${latestState.activeChatSession}`, 'ok');
1169
+ } else {
1170
+ setStatus('Rebind cancelled.', 'warn');
1171
+ }
1172
+ latestState = redraw();
1173
+ return;
1174
+ }
1175
+ if (key?.name === 'c') {
1176
+ const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
1177
+ if (!selected?.conversation_id) return;
1178
+ removeSessionBinding(selected.conversation_id);
1179
+ setStatus(`Cleared binding for ${selected.conversation_id}`, 'ok');
1180
+ latestState = redraw();
1181
+ return;
1182
+ }
1183
+ if (key?.name === 'x' && (uiState.view === 'tasks' || uiState.view === 'task-detail')) {
1184
+ const selected = latestState.selectedSession;
1185
+ const tasks = latestState.selectedTasks || [];
1186
+ const task = tasks[Math.max(0, Math.min(uiState.selectedTaskIndex, Math.max(0, tasks.length - 1)))] || null;
1187
+ if (!selected?.conversation_id || !task) return;
1188
+ const confirmed = await confirmAction(`Cancel task ${task.task_id}\nTitle: ${task.title || '(none)'}\nStatus: ${task.status}\nProceed?`);
1189
+ if (confirmed) {
1190
+ const { client, store } = await getClientWithStore(identityPath);
1191
+ try {
1192
+ const res = await client.cancelTask(selected.conversation_id, task.task_id);
1193
+ setStatus(res.ok
1194
+ ? `Cancel requested for task ${task.task_id}`
1195
+ : `Cancel failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
1196
+ } finally {
1197
+ store.close();
1198
+ }
1199
+ } else {
1200
+ setStatus('Cancel task aborted.', 'warn');
1201
+ }
1202
+ latestState = redraw();
1203
+ return;
1204
+ }
1205
+ if (key?.name === 'f') {
1206
+ const confirmed = await confirmAction('Repair OpenClaw hooks config now? A timestamped backup will be written first.');
1207
+ if (!confirmed) {
1208
+ setStatus('Hooks repair cancelled.', 'warn');
1209
+ latestState = redraw();
1210
+ return;
1211
+ }
1212
+ const result = runOpenClawInstall(['fix-hooks']);
1213
+ setStatus(result.ok
1214
+ ? `Hooks repaired.${result.stdout ? ` ${truncateLine(result.stdout.replace(/\s+/g, ' '), 100)}` : ''}`
1215
+ : `Hooks repair failed: ${truncateLine(result.stderr || result.stdout || 'unknown error', 120)}`,
1216
+ result.ok ? 'ok' : 'err', result.ok ? 7000 : 9000);
1217
+ latestState = redraw();
1218
+ return;
1219
+ }
1220
+ if (key?.name === 'y' && uiState.view === 'task-detail') {
1221
+ const tasks = latestState.selectedTasks || [];
1222
+ const task = tasks[Math.max(0, Math.min(uiState.selectedTaskIndex, Math.max(0, tasks.length - 1)))] || null;
1223
+ if (!task) return;
1224
+ const formatAnswer = await promptLine('Export format [j]son / [p]lain (default plain): ');
1225
+ const format = formatAnswer.trim().toLowerCase().startsWith('j') ? 'json' : 'plain';
1226
+ await dumpStdoutBlock(`task ${task.task_id} (${format})`, buildTaskExportBody(task, format));
1227
+ setStatus(`Dumped task ${task.task_id} to stdout as ${format}.`, 'ok');
1228
+ latestState = redraw();
1229
+ return;
1230
+ }
1231
+ });
1232
+ }
1233
+
80
1234
  /** Format timestamp for display: human-readable by default, raw ms when --raw. */
81
1235
  function formatTs(tsMs, raw) {
82
1236
  if (raw) return String(tsMs);
@@ -94,6 +1248,19 @@ function makeClient(id, identityPath) {
94
1248
  });
95
1249
  }
96
1250
 
1251
+ function makeClientWithStore(id, identityPath) {
1252
+ const p = identityPath ?? getEffectiveIdentityPath();
1253
+ const store = openStore(p);
1254
+ const client = new PingAgentClient({
1255
+ serverUrl: id.serverUrl ?? DEFAULT_SERVER,
1256
+ identity: id,
1257
+ accessToken: id.accessToken ?? '',
1258
+ onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, p),
1259
+ store,
1260
+ });
1261
+ return { client, store };
1262
+ }
1263
+
97
1264
  /** Load identity, proactively refresh token if near/past expiry, then return client. */
98
1265
  async function getClient(identityPath) {
99
1266
  const p = identityPath ?? getEffectiveIdentityPath();
@@ -105,6 +1272,61 @@ async function getClient(identityPath) {
105
1272
  return client;
106
1273
  }
107
1274
 
1275
+ async function getClientWithStore(identityPath) {
1276
+ const p = identityPath ?? getEffectiveIdentityPath();
1277
+ let id = loadIdentity(p);
1278
+ await ensureTokenValid(p, id.serverUrl);
1279
+ id = loadIdentity(p);
1280
+ const { client, store } = makeClientWithStore(id, p);
1281
+ await client.ensureEncryptionKeyPublished().catch(() => undefined);
1282
+ return { client, store };
1283
+ }
1284
+
1285
+ function resolveSessionFromStore(store, args = {}) {
1286
+ const sessionManager = new SessionManager(store);
1287
+ let session = args.sessionKey ? sessionManager.get(args.sessionKey) : null;
1288
+ if (!session && args.conversationId) session = sessionManager.getByConversationId(args.conversationId);
1289
+ if (!session && args.remoteDid) {
1290
+ session = sessionManager.listRecentSessions(200).find((item) => item.remote_did === args.remoteDid) ?? null;
1291
+ }
1292
+ return session ?? sessionManager.getActiveSession() ?? sessionManager.listRecentSessions(1)[0] ?? null;
1293
+ }
1294
+
1295
+ async function resolveTarget(client, target) {
1296
+ const value = String(target ?? '').trim();
1297
+ if (!value) throw new Error('Missing target');
1298
+ if (value.startsWith('did:agent:')) return value;
1299
+ if (value.startsWith('@')) {
1300
+ const resolved = await client.resolveAlias(value.slice(1));
1301
+ if (!resolved.ok || !resolved.data?.did) {
1302
+ throw new Error(resolved.error?.message || `Cannot resolve alias ${value}`);
1303
+ }
1304
+ return resolved.data.did;
1305
+ }
1306
+ const publicAgent = await client.getPublicAgent(value).catch(() => ({ ok: false }));
1307
+ if (publicAgent.ok && publicAgent.data?.did) return publicAgent.data.did;
1308
+ return value;
1309
+ }
1310
+
1311
+ async function maybeEnsureHostedPublicLink(identityPath) {
1312
+ const p = identityPath ?? getEffectiveIdentityPath();
1313
+ const id = loadIdentity(p);
1314
+ if (!isOfficialHostedServer(id.serverUrl ?? DEFAULT_SERVER)) return null;
1315
+ const key = `${p}:${normalizeOrigin(id.serverUrl ?? DEFAULT_SERVER)}`;
1316
+ if (hostedPublicLinkAttempts.has(key)) return null;
1317
+ hostedPublicLinkAttempts.add(key);
1318
+ try {
1319
+ const client = await getClient(p);
1320
+ const current = await client.getPublicSelf().catch(() => ({ ok: false }));
1321
+ if (current.ok && current.data?.public_url) return { created: false, data: current.data };
1322
+ const created = await client.createPublicLink({ enabled: true }).catch(() => ({ ok: false }));
1323
+ if (created.ok && created.data) return { created: true, data: created.data };
1324
+ return null;
1325
+ } catch {
1326
+ return null;
1327
+ }
1328
+ }
1329
+
108
1330
  const program = new Command();
109
1331
  program
110
1332
  .name('pingagent')
@@ -1438,6 +2660,486 @@ billing
1438
2660
  }
1439
2661
  });
1440
2662
 
2663
+ const publicCmd = program.command('public').description('Hosted public growth surface: shareable profile links, contact cards, and task shares');
2664
+
2665
+ publicCmd
2666
+ .command('link')
2667
+ .description('Create or update your hosted public share link')
2668
+ .option('--slug <slug>', 'Preferred public slug')
2669
+ .option('--json', 'Output as JSON')
2670
+ .action(async (opts) => {
2671
+ const client = await getClient();
2672
+ const res = await client.createPublicLink({ slug: opts.slug });
2673
+ if (!res.ok) {
2674
+ if (opts.json) console.log(JSON.stringify(res, null, 2));
2675
+ else printError(res.error);
2676
+ process.exit(1);
2677
+ }
2678
+ if (opts.json) console.log(JSON.stringify(res.data, null, 2));
2679
+ else {
2680
+ console.log(`Public slug: ${res.data.public_slug || '(none)'}`);
2681
+ console.log(`Canonical: ${res.data.canonical_slug || '(none)'}`);
2682
+ console.log(`URL: ${res.data.public_url || '(none)'}`);
2683
+ }
2684
+ });
2685
+
2686
+ publicCmd
2687
+ .command('profile')
2688
+ .description('Show your hosted public share state')
2689
+ .option('--json', 'Output as JSON')
2690
+ .action(async (opts) => {
2691
+ const client = await getClient();
2692
+ await maybeEnsureHostedPublicLink();
2693
+ const res = await client.getPublicSelf();
2694
+ if (!res.ok) {
2695
+ if (opts.json) console.log(JSON.stringify(res, null, 2));
2696
+ else printError(res.error);
2697
+ process.exit(1);
2698
+ }
2699
+ if (opts.json) console.log(JSON.stringify(res.data, null, 2));
2700
+ else {
2701
+ console.log(`DID: ${res.data.did}`);
2702
+ console.log(`Alias: ${res.data.alias || '(none)'}`);
2703
+ console.log(`Public slug: ${res.data.public_slug || '(none)'}`);
2704
+ console.log(`Enabled: ${res.data.public_share_enabled ? 'yes' : 'no'}`);
2705
+ console.log(`Discoverable:${res.data.discoverable ? ' yes' : ' no'}`);
2706
+ console.log(`URL: ${res.data.public_url || '(none)'}`);
2707
+ }
2708
+ });
2709
+
2710
+ publicCmd
2711
+ .command('contact-card')
2712
+ .description('Create a shareable contact card for this agent or another target DID')
2713
+ .option('--target-did <did>', 'Target DID to place in the contact card')
2714
+ .option('--intro-note <text>', 'Intro note shown on the card')
2715
+ .option('--message-template <text>', 'Suggested first-message template')
2716
+ .option('--json', 'Output as JSON')
2717
+ .action(async (opts) => {
2718
+ const client = await getClient();
2719
+ const res = await client.createContactCard({
2720
+ target_did: opts.targetDid,
2721
+ intro_note: opts.introNote,
2722
+ message_template: opts.messageTemplate,
2723
+ });
2724
+ if (!res.ok) {
2725
+ if (opts.json) console.log(JSON.stringify(res, null, 2));
2726
+ else printError(res.error);
2727
+ process.exit(1);
2728
+ }
2729
+ if (opts.json) console.log(JSON.stringify(res.data, null, 2));
2730
+ else {
2731
+ console.log(`Contact card: ${res.data.id}`);
2732
+ console.log(`Target DID: ${res.data.target_did}`);
2733
+ console.log(`URL: ${res.data.share_url || '(none)'}`);
2734
+ }
2735
+ });
2736
+
2737
+ publicCmd
2738
+ .command('task-share')
2739
+ .description('Publish a shareable task result summary (explicit publish only)')
2740
+ .requiredOption('--summary <text>', 'Generated summary to publish')
2741
+ .option('--task-id <id>', 'Task ID')
2742
+ .option('--title <title>', 'Task title')
2743
+ .option('--status <status>', 'Task status', 'processed')
2744
+ .option('--conversation <id>', 'Conversation ID')
2745
+ .option('--json', 'Output as JSON')
2746
+ .action(async (opts) => {
2747
+ const client = await getClient();
2748
+ const res = await client.createTaskShare({
2749
+ task_id: opts.taskId,
2750
+ title: opts.title,
2751
+ status: opts.status,
2752
+ summary: opts.summary,
2753
+ conversation_id: opts.conversation,
2754
+ });
2755
+ if (!res.ok) {
2756
+ if (opts.json) console.log(JSON.stringify(res, null, 2));
2757
+ else printError(res.error);
2758
+ process.exit(1);
2759
+ }
2760
+ if (opts.json) console.log(JSON.stringify(res.data, null, 2));
2761
+ else {
2762
+ console.log(`Task share: ${res.data.id}`);
2763
+ console.log(`URL: ${res.data.share_url || '(none)'}`);
2764
+ }
2765
+ });
2766
+
2767
+ program
2768
+ .command('capability-card')
2769
+ .description('Show or update the structured machine-readable capability card for this agent')
2770
+ .option('--summary <text>', 'Capability card summary')
2771
+ .option('--accepts-new-work <value>', 'true or false')
2772
+ .option('--preferred-contact-mode <mode>', 'dm, task, or either')
2773
+ .option('--capability-item <json>', 'Capability item JSON; repeat to add/replace entries by id', (value, acc) => {
2774
+ acc.push(value);
2775
+ return acc;
2776
+ }, [])
2777
+ .option('--replace-items', 'Replace capability items with the provided --capability-item rows instead of merging by id')
2778
+ .option('--json', 'Output as JSON')
2779
+ .action(async (opts) => {
2780
+ const client = await getClient();
2781
+ const profileRes = await client.getProfile();
2782
+ if (!profileRes.ok || !profileRes.data) {
2783
+ if (opts.json) console.log(JSON.stringify(profileRes, null, 2));
2784
+ else printError(profileRes.error);
2785
+ process.exit(1);
2786
+ }
2787
+ const current = profileRes.data.capability_card || { version: '1', capabilities: [] };
2788
+ const shouldUpdate =
2789
+ opts.summary !== undefined
2790
+ || opts.acceptsNewWork !== undefined
2791
+ || opts.preferredContactMode !== undefined
2792
+ || (opts.capabilityItem && opts.capabilityItem.length > 0)
2793
+ || opts.replaceItems;
2794
+ if (!shouldUpdate) {
2795
+ if (opts.json) console.log(JSON.stringify(current, null, 2));
2796
+ else {
2797
+ console.log(`summary=${formatCapabilityCardSummary(current)}`);
2798
+ console.log(JSON.stringify(current, null, 2));
2799
+ }
2800
+ return;
2801
+ }
2802
+ let capabilityItems = Array.isArray(current.capabilities) ? [...current.capabilities] : [];
2803
+ if (opts.capabilityItem && opts.capabilityItem.length > 0) {
2804
+ const parsedItems = opts.capabilityItem.map((item) => JSON.parse(item));
2805
+ if (opts.replaceItems) {
2806
+ capabilityItems = parsedItems;
2807
+ } else {
2808
+ const byId = new Map(capabilityItems.map((item) => [item.id, item]));
2809
+ for (const item of parsedItems) {
2810
+ byId.set(item.id, { ...(byId.get(item.id) || {}), ...item });
2811
+ }
2812
+ capabilityItems = Array.from(byId.values());
2813
+ }
2814
+ } else if (opts.replaceItems) {
2815
+ capabilityItems = [];
2816
+ }
2817
+ const nextCard = {
2818
+ version: current.version || '1',
2819
+ summary: opts.summary !== undefined ? opts.summary : current.summary,
2820
+ accepts_new_work: opts.acceptsNewWork !== undefined
2821
+ ? String(opts.acceptsNewWork).trim().toLowerCase() === 'true'
2822
+ : current.accepts_new_work,
2823
+ preferred_contact_mode: opts.preferredContactMode !== undefined
2824
+ ? opts.preferredContactMode
2825
+ : current.preferred_contact_mode,
2826
+ capabilities: capabilityItems,
2827
+ };
2828
+ const updateRes = await client.updateProfile({ capability_card: nextCard });
2829
+ if (!updateRes.ok || !updateRes.data) {
2830
+ if (opts.json) console.log(JSON.stringify(updateRes, null, 2));
2831
+ else printError(updateRes.error);
2832
+ process.exit(1);
2833
+ }
2834
+ if (opts.json) console.log(JSON.stringify(updateRes.data.capability_card || nextCard, null, 2));
2835
+ else {
2836
+ console.log(`summary=${formatCapabilityCardSummary(updateRes.data.capability_card || nextCard)}`);
2837
+ console.log(JSON.stringify(updateRes.data.capability_card || nextCard, null, 2));
2838
+ }
2839
+ });
2840
+
2841
+ program
2842
+ .command('session-summary')
2843
+ .description('Show or update the local carry-forward summary for a session')
2844
+ .option('--session-key <key>', 'Exact session key')
2845
+ .option('--conversation-id <id>', 'Conversation ID')
2846
+ .option('--remote-did <did>', 'Remote DID')
2847
+ .option('--objective <text>', 'Current objective')
2848
+ .option('--context <text>', 'Current context')
2849
+ .option('--constraints <text>', 'Constraints')
2850
+ .option('--decisions <text>', 'Decisions already made')
2851
+ .option('--open-questions <text>', 'Open questions')
2852
+ .option('--next-action <text>', 'Next action')
2853
+ .option('--handoff-ready-text <text>', 'Handoff-ready summary text')
2854
+ .option('--clear-field <field>', 'Clear one summary field; repeatable', (value, acc) => {
2855
+ acc.push(value);
2856
+ return acc;
2857
+ }, [])
2858
+ .option('--json', 'Output as JSON')
2859
+ .action(async (opts) => {
2860
+ const identityPath = getIdentityPathForCommand(opts);
2861
+ const store = openStore(identityPath);
2862
+ try {
2863
+ const session = resolveSessionFromStore(store, {
2864
+ sessionKey: opts.sessionKey,
2865
+ conversationId: opts.conversationId,
2866
+ remoteDid: opts.remoteDid,
2867
+ });
2868
+ if (!session) {
2869
+ console.error('No session found. Use pingagent host tui or pingagent recent sessions first.');
2870
+ process.exit(1);
2871
+ }
2872
+ const manager = new SessionSummaryManager(store);
2873
+ const clearFields = Array.isArray(opts.clearField)
2874
+ ? opts.clearField
2875
+ .map((value) => String(value || '').trim())
2876
+ .filter((value) => SESSION_SUMMARY_FIELDS.includes(value))
2877
+ : [];
2878
+ if (Array.isArray(opts.clearField) && opts.clearField.length !== clearFields.length) {
2879
+ console.error(`clear-field must be one of: ${SESSION_SUMMARY_FIELDS.join(', ')}`);
2880
+ process.exit(1);
2881
+ }
2882
+ const shouldUpdate = [
2883
+ opts.objective,
2884
+ opts.context,
2885
+ opts.constraints,
2886
+ opts.decisions,
2887
+ opts.openQuestions,
2888
+ opts.nextAction,
2889
+ opts.handoffReadyText,
2890
+ ].some((value) => value !== undefined) || clearFields.length > 0;
2891
+ let summary = shouldUpdate
2892
+ ? manager.upsert({
2893
+ session_key: session.session_key,
2894
+ objective: opts.objective,
2895
+ context: opts.context,
2896
+ constraints: opts.constraints,
2897
+ decisions: opts.decisions,
2898
+ open_questions: opts.openQuestions,
2899
+ next_action: opts.nextAction,
2900
+ handoff_ready_text: opts.handoffReadyText,
2901
+ })
2902
+ : manager.get(session.session_key);
2903
+ if (clearFields.length > 0) {
2904
+ summary = manager.clearFields(session.session_key, clearFields);
2905
+ }
2906
+ if (opts.json) {
2907
+ console.log(JSON.stringify({ session, summary }, null, 2));
2908
+ } else {
2909
+ console.log(`session=${session.session_key}`);
2910
+ console.log(`conversation=${session.conversation_id || '(none)'}`);
2911
+ console.log(`remote=${session.remote_did || '(unknown)'}`);
2912
+ console.log(JSON.stringify(summary, null, 2));
2913
+ }
2914
+ } finally {
2915
+ store.close();
2916
+ }
2917
+ });
2918
+
2919
+ program
2920
+ .command('handoff')
2921
+ .description('Send a first-class delegation / handoff task using the current task-thread transport')
2922
+ .requiredOption('--to <target>', 'Target DID, alias, public slug, or connectable identity')
2923
+ .requiredOption('--title <title>', 'Task title')
2924
+ .option('--description <text>', 'Task description')
2925
+ .option('--objective <text>', 'Delegation objective')
2926
+ .option('--success-criteria <text>', 'Success criteria')
2927
+ .option('--priority <text>', 'Priority label')
2928
+ .option('--carry-forward-summary <text>', 'Explicit carry-forward summary; otherwise use the session summary')
2929
+ .option('--callback-session-key <key>', 'Callback session key to reference in the handoff')
2930
+ .option('--session-key <key>', 'Source session key whose summary should be used')
2931
+ .option('--conversation-id <id>', 'Source conversation ID whose summary should be used')
2932
+ .option('--remote-did <did>', 'Source remote DID whose summary should be used')
2933
+ .option('--json', 'Output as JSON')
2934
+ .action(async (opts) => {
2935
+ const identityPath = getIdentityPathForCommand(opts);
2936
+ const { client, store } = await getClientWithStore(identityPath);
2937
+ try {
2938
+ const targetDid = await resolveTarget(client, opts.to);
2939
+ const sourceSession = resolveSessionFromStore(store, {
2940
+ sessionKey: opts.sessionKey,
2941
+ conversationId: opts.conversationId,
2942
+ remoteDid: opts.remoteDid,
2943
+ });
2944
+ const result = await client.sendHandoff(targetDid, {
2945
+ title: opts.title,
2946
+ description: opts.description,
2947
+ objective: opts.objective,
2948
+ carry_forward_summary: opts.carryForwardSummary,
2949
+ success_criteria: opts.successCriteria,
2950
+ callback_session_key: opts.callbackSessionKey || sourceSession?.session_key,
2951
+ priority: opts.priority,
2952
+ }, {
2953
+ sessionKey: sourceSession?.session_key,
2954
+ conversationId: sourceSession?.conversation_id,
2955
+ });
2956
+ if (!result.ok || !result.data) {
2957
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
2958
+ else printError(result.error);
2959
+ process.exit(1);
2960
+ }
2961
+ if (opts.json) {
2962
+ console.log(JSON.stringify({
2963
+ target_did: targetDid,
2964
+ source_session_key: sourceSession?.session_key ?? null,
2965
+ ...result.data,
2966
+ }, null, 2));
2967
+ } else {
2968
+ console.log(`task_id=${result.data.task_id}`);
2969
+ console.log(`conversation_id=${result.data.conversation_id}`);
2970
+ console.log(`target_did=${targetDid}`);
2971
+ console.log(`source_session_key=${sourceSession?.session_key || '(none)'}`);
2972
+ console.log(JSON.stringify(result.data.handoff, null, 2));
2973
+ }
2974
+ } finally {
2975
+ store.close();
2976
+ }
2977
+ });
2978
+
2979
+ program
2980
+ .command('demo')
2981
+ .description('Open or message the official PingAgent demo agent')
2982
+ .option('--preset <name>', 'Preset first message: hello, delegate, or trust')
2983
+ .option('--message <text>', 'Optional first message to send immediately')
2984
+ .option('--json', 'Output as JSON')
2985
+ .action(async (opts) => {
2986
+ const client = await getClient();
2987
+ const resolved = await client.resolveAlias('pingagent/demo');
2988
+ if (!resolved.ok || !resolved.data?.did) {
2989
+ if (opts.json) console.log(JSON.stringify(resolved, null, 2));
2990
+ else printError(resolved.error);
2991
+ process.exit(1);
2992
+ }
2993
+ const convo = await client.openConversation(resolved.data.did);
2994
+ if (!convo.ok || !convo.data) {
2995
+ if (opts.json) console.log(JSON.stringify(convo, null, 2));
2996
+ else printError(convo.error);
2997
+ process.exit(1);
2998
+ }
2999
+ const presetMessages = {
3000
+ hello: 'Hello',
3001
+ delegate: 'Please show me how task delegation works in PingAgent.',
3002
+ trust: 'Show me how trust decisions and recommendations work.',
3003
+ };
3004
+ const effectiveMessage = typeof opts.message === 'string' && opts.message.trim()
3005
+ ? opts.message
3006
+ : (typeof opts.preset === 'string' && presetMessages[opts.preset.trim().toLowerCase()] ? presetMessages[opts.preset.trim().toLowerCase()] : '');
3007
+ if (effectiveMessage) {
3008
+ const sendRes = await client.sendMessage(convo.data.conversation_id, SCHEMA_TEXT, { text: effectiveMessage });
3009
+ if (!sendRes.ok) {
3010
+ if (opts.json) console.log(JSON.stringify(sendRes, null, 2));
3011
+ else printError(sendRes.error);
3012
+ process.exit(1);
3013
+ }
3014
+ if (opts.json) console.log(JSON.stringify({ did: resolved.data.did, conversation_id: convo.data.conversation_id, message_id: sendRes.data?.message_id, preset: opts.preset ?? null }, null, 2));
3015
+ else console.log(`Demo agent messaged. conversation=${convo.data.conversation_id} message=${sendRes.data?.message_id}`);
3016
+ return;
3017
+ }
3018
+ if (opts.json) console.log(JSON.stringify({ did: resolved.data.did, conversation_id: convo.data.conversation_id }, null, 2));
3019
+ else console.log(`Demo agent ready. did=${resolved.data.did} conversation=${convo.data.conversation_id}`);
3020
+ });
3021
+
3022
+ program
3023
+ .command('connect')
3024
+ .description('Consume a PingAgent public link or contact card and open a conversation')
3025
+ .argument('<target>', 'Share URL, contact card URL, public slug, alias, or DID')
3026
+ .option('--message <text>', 'Optional first message to send immediately')
3027
+ .option('--json', 'Output as JSON')
3028
+ .action(async (target, opts) => {
3029
+ const client = await getClient();
3030
+ let targetDid = '';
3031
+ let prefMessage = typeof opts.message === 'string' ? opts.message : '';
3032
+ try {
3033
+ if (/^https?:\/\//.test(target)) {
3034
+ const url = new URL(target);
3035
+ const parts = url.pathname.split('/').filter(Boolean);
3036
+ if (parts[0] === 'c' && parts[1]) {
3037
+ const card = await client.getContactCard(parts[1]);
3038
+ if (!card.ok || !card.data) {
3039
+ if (opts.json) console.log(JSON.stringify(card, null, 2));
3040
+ else printError(card.error);
3041
+ process.exit(1);
3042
+ }
3043
+ targetDid = card.data.target_did;
3044
+ if (!prefMessage && card.data.message_template) prefMessage = card.data.message_template;
3045
+ } else if (parts[0] === 'a' && parts[1]) {
3046
+ const agent = await client.getPublicAgent(parts[1]);
3047
+ if (!agent.ok || !agent.data?.did) {
3048
+ if (opts.json) console.log(JSON.stringify(agent, null, 2));
3049
+ else printError(agent.error);
3050
+ process.exit(1);
3051
+ }
3052
+ targetDid = agent.data.did;
3053
+ } else if (parts[0] === 'connect' && parts[1]) {
3054
+ const requestedTarget = decodeURIComponent(parts[1]);
3055
+ if (!prefMessage && url.searchParams.get('message')) {
3056
+ prefMessage = url.searchParams.get('message') || '';
3057
+ }
3058
+ if (requestedTarget.startsWith('did:agent:')) {
3059
+ targetDid = requestedTarget;
3060
+ } else if (requestedTarget.startsWith('@')) {
3061
+ const resolved = await client.resolveAlias(requestedTarget.slice(1));
3062
+ if (!resolved.ok || !resolved.data?.did) {
3063
+ if (opts.json) console.log(JSON.stringify(resolved, null, 2));
3064
+ else printError(resolved.error);
3065
+ process.exit(1);
3066
+ }
3067
+ targetDid = resolved.data.did;
3068
+ } else {
3069
+ const agent = await client.getPublicAgent(requestedTarget);
3070
+ if (!agent.ok || !agent.data?.did) {
3071
+ if (opts.json) console.log(JSON.stringify(agent, null, 2));
3072
+ else printError(agent.error);
3073
+ process.exit(1);
3074
+ }
3075
+ targetDid = agent.data.did;
3076
+ }
3077
+ }
3078
+ }
3079
+ } catch {
3080
+ // fall through to text target handling
3081
+ }
3082
+ if (!targetDid) {
3083
+ if (String(target).startsWith('did:agent:')) {
3084
+ targetDid = String(target);
3085
+ } else if (String(target).startsWith('@')) {
3086
+ const resolved = await client.resolveAlias(String(target).slice(1));
3087
+ if (!resolved.ok || !resolved.data?.did) {
3088
+ if (opts.json) console.log(JSON.stringify(resolved, null, 2));
3089
+ else printError(resolved.error);
3090
+ process.exit(1);
3091
+ }
3092
+ targetDid = resolved.data.did;
3093
+ } else {
3094
+ const agent = await client.getPublicAgent(String(target));
3095
+ if (!agent.ok || !agent.data?.did) {
3096
+ if (opts.json) console.log(JSON.stringify(agent, null, 2));
3097
+ else printError(agent.error);
3098
+ process.exit(1);
3099
+ }
3100
+ targetDid = agent.data.did;
3101
+ }
3102
+ }
3103
+ const convo = await client.openConversation(targetDid);
3104
+ if (!convo.ok || !convo.data) {
3105
+ if (opts.json) console.log(JSON.stringify(convo, null, 2));
3106
+ else printError(convo.error);
3107
+ process.exit(1);
3108
+ }
3109
+ if (prefMessage) {
3110
+ const sendRes = await client.sendMessage(convo.data.conversation_id, SCHEMA_TEXT, { text: prefMessage });
3111
+ if (!sendRes.ok) {
3112
+ if (opts.json) console.log(JSON.stringify(sendRes, null, 2));
3113
+ else printError(sendRes.error);
3114
+ process.exit(1);
3115
+ }
3116
+ if (opts.json) console.log(JSON.stringify({ did: targetDid, conversation_id: convo.data.conversation_id, message_id: sendRes.data?.message_id }, null, 2));
3117
+ else console.log(`Connected and sent first message. conversation=${convo.data.conversation_id} did=${targetDid}`);
3118
+ return;
3119
+ }
3120
+ if (opts.json) console.log(JSON.stringify({ did: targetDid, conversation_id: convo.data.conversation_id }, null, 2));
3121
+ else console.log(`Connected. conversation=${convo.data.conversation_id} did=${targetDid}`);
3122
+ });
3123
+
3124
+ const host = program
3125
+ .command('host')
3126
+ .description('Headless runtime inspection and control for PingAgent host state');
3127
+
3128
+ host
3129
+ .command('tui')
3130
+ .description('Start a terminal UI for runtime, sessions, bindings, and rebind actions')
3131
+ .option('--once', 'Print one snapshot and exit')
3132
+ .option('--refresh-ms <ms>', 'Refresh interval in interactive mode', '2000')
3133
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
3134
+ .action(async (opts) => {
3135
+ const identityPath = getIdentityPathForCommand(opts);
3136
+ if (!identityExists(identityPath)) {
3137
+ console.error('No identity found. Run: pingagent init');
3138
+ process.exit(1);
3139
+ }
3140
+ await runHostTui(identityPath, opts);
3141
+ });
3142
+
1441
3143
  program
1442
3144
  .command('web')
1443
3145
  .description('Start local web UI and host panel for debugging, runtime inspection, trust policy, and audit. By default scans ~/.pingagent for profiles; use --identity-dir to lock to one profile.')