@pingagent/sdk 0.1.9 → 0.1.10

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,7 @@ 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';
6
7
  import {
7
8
  PingAgentClient,
8
9
  generateIdentity,
@@ -15,6 +16,19 @@ import {
15
16
  ContactManager,
16
17
  HistoryManager,
17
18
  A2AAdapter,
19
+ SessionManager,
20
+ TaskThreadManager,
21
+ TrustPolicyAuditManager,
22
+ defaultTrustPolicyDoc,
23
+ normalizeTrustPolicyDoc,
24
+ getActiveSessionFilePath,
25
+ getSessionMapFilePath,
26
+ getSessionBindingAlertsFilePath,
27
+ readCurrentActiveSessionKey,
28
+ readSessionBindings,
29
+ readSessionBindingAlerts,
30
+ setSessionBinding,
31
+ removeSessionBinding,
18
32
  } from '../dist/index.js';
19
33
  import { ERROR_HINTS, SCHEMA_TEXT } from '@pingagent/schemas';
20
34
 
@@ -77,6 +91,780 @@ function openStore(identityPath) {
77
91
  return new LocalStore(getStorePath(identityPath));
78
92
  }
79
93
 
94
+ function getTrustPolicyPath(identityPath) {
95
+ return process.env.PINGAGENT_TRUST_POLICY_PATH
96
+ ? resolvePath(process.env.PINGAGENT_TRUST_POLICY_PATH)
97
+ : path.join(path.dirname(identityPath), 'trust-policy.json');
98
+ }
99
+
100
+ function readTrustPolicyDoc(identityPath) {
101
+ const policyPath = getTrustPolicyPath(identityPath);
102
+ try {
103
+ if (!fs.existsSync(policyPath)) return { path: policyPath, doc: defaultTrustPolicyDoc() };
104
+ const raw = JSON.parse(fs.readFileSync(policyPath, 'utf-8'));
105
+ return { path: policyPath, doc: normalizeTrustPolicyDoc(raw) };
106
+ } catch {
107
+ return { path: policyPath, doc: defaultTrustPolicyDoc() };
108
+ }
109
+ }
110
+
111
+ function clearScreen() {
112
+ process.stdout.write('\x1Bc');
113
+ }
114
+
115
+ function ansi(code, text) {
116
+ return process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
117
+ }
118
+
119
+ function formatStatusLine(level, message) {
120
+ const tag = level === 'ok'
121
+ ? ansi('32', '[OK]')
122
+ : level === 'warn'
123
+ ? ansi('33', '[WARN]')
124
+ : level === 'err'
125
+ ? ansi('31', '[ERR]')
126
+ : ansi('36', '[INFO]');
127
+ return `${tag} ${message || '(ready)'}`;
128
+ }
129
+
130
+ function formatStatusTimestamp(tsMs) {
131
+ if (!tsMs) return '';
132
+ return formatTs(tsMs, false);
133
+ }
134
+
135
+ function truncateLine(value, max = 100) {
136
+ const s = String(value ?? '');
137
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
138
+ }
139
+
140
+ function formatSessionRow(session, selected) {
141
+ const marker = selected ? '>' : ' ';
142
+ const rebind = session.binding_alert ? ' !rebind' : '';
143
+ const trust = session.trust_state || 'unknown';
144
+ const unread = session.unread_count ?? 0;
145
+ const who = truncateLine(session.remote_did || session.conversation_id || 'unknown', 40);
146
+ return `${marker} ${who} [${trust}] unread=${unread}${rebind}`;
147
+ }
148
+
149
+ function formatMessageRow(message) {
150
+ const ts = formatTs(message.ts_ms ?? message.receivedAtMs ?? Date.now(), false);
151
+ const direction = message.direction || 'unknown';
152
+ const schema = message.schema || 'unknown';
153
+ const payload = message.payload || {};
154
+ const summary = schema === SCHEMA_TEXT && payload.text
155
+ ? payload.text
156
+ : JSON.stringify(payload);
157
+ return `- ${ts} ${direction} ${schema} ${truncateLine(summary, 90)}`;
158
+ }
159
+
160
+ function buildTaskExportBody(task, format = 'plain') {
161
+ const payload = {
162
+ task_id: task.task_id,
163
+ title: task.title || null,
164
+ status: task.status,
165
+ updated_at: task.updated_at ?? null,
166
+ updated_at_display: task.updated_at ? formatTs(task.updated_at, false) : null,
167
+ started_at: task.started_at ?? null,
168
+ started_at_display: task.started_at ? formatTs(task.started_at, false) : null,
169
+ result: task.result_summary || null,
170
+ error: task.error_message
171
+ ? {
172
+ code: task.error_code || 'E_TASK',
173
+ message: task.error_message,
174
+ }
175
+ : null,
176
+ };
177
+ if (format === 'json') return JSON.stringify(payload, null, 2);
178
+ return [
179
+ `task_id=${payload.task_id}`,
180
+ `title=${payload.title || '(none)'}`,
181
+ `status=${payload.status}`,
182
+ `updated_at=${payload.updated_at_display || '(none)'}`,
183
+ payload.started_at_display ? `started_at=${payload.started_at_display}` : null,
184
+ '',
185
+ '[result]',
186
+ payload.result || '(none)',
187
+ '',
188
+ '[error]',
189
+ payload.error ? `${payload.error.code} ${payload.error.message}` : '(none)',
190
+ ].filter(Boolean).join('\n');
191
+ }
192
+
193
+ function loadHistoryPage(historyManager, conversationId, pageIndex, pageSize) {
194
+ if (!conversationId) return { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
195
+ let page = Math.max(0, pageIndex || 0);
196
+ let messages = historyManager.listRecent(conversationId, pageSize);
197
+ if (messages.length === 0) {
198
+ return { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
199
+ }
200
+ for (let i = 0; i < page; i++) {
201
+ const first = messages[0];
202
+ if (!first) {
203
+ page = i;
204
+ messages = [];
205
+ break;
206
+ }
207
+ const older = first.seq != null
208
+ ? historyManager.listBeforeSeq(conversationId, first.seq, pageSize)
209
+ : historyManager.listBeforeTs(conversationId, first.ts_ms, pageSize);
210
+ if (!older.length) {
211
+ page = i;
212
+ break;
213
+ }
214
+ messages = older;
215
+ }
216
+ const first = messages[0];
217
+ const hasOlder = !!first && (
218
+ first.seq != null
219
+ ? historyManager.listBeforeSeq(conversationId, first.seq, 1).length > 0
220
+ : historyManager.listBeforeTs(conversationId, first.ts_ms, 1).length > 0
221
+ );
222
+ return {
223
+ messages,
224
+ pageIndex: page,
225
+ hasOlder,
226
+ hasNewer: page > 0,
227
+ };
228
+ }
229
+
230
+ function buildHostState(identityPath, selectedSessionKey = null, historyPageIndex = 0, historySearchQuery = '') {
231
+ const identity = loadIdentity(identityPath);
232
+ const store = openStore(identityPath);
233
+ try {
234
+ const sessionManager = new SessionManager(store);
235
+ const taskManager = new TaskThreadManager(store);
236
+ const historyManager = new HistoryManager(store);
237
+ const auditManager = new TrustPolicyAuditManager(store);
238
+ const sessions = sessionManager.listRecentSessions(50);
239
+ const bindings = readSessionBindings();
240
+ const alerts = readSessionBindingAlerts();
241
+ const bindingByConversation = new Map(bindings.map((row) => [row.conversation_id, row]));
242
+ const alertByConversation = new Map(alerts.map((row) => [row.conversation_id, row]));
243
+ const activeChatSession = readCurrentActiveSessionKey();
244
+ const desiredSelectedSessionKey =
245
+ (selectedSessionKey && sessions.some((session) => session.session_key === selectedSessionKey) ? selectedSessionKey : null) ??
246
+ sessionManager.getActiveSession()?.session_key ??
247
+ sessions[0]?.session_key ??
248
+ null;
249
+ const policy = readTrustPolicyDoc(identityPath);
250
+ const runtimeMode = process.env.PINGAGENT_RUNTIME_MODE || 'bridge';
251
+ const sessionsWithMeta = sessions.map((session) => ({
252
+ ...session,
253
+ binding: session.conversation_id ? (bindingByConversation.get(session.conversation_id) ?? null) : null,
254
+ binding_alert: session.conversation_id ? (alertByConversation.get(session.conversation_id) ?? null) : null,
255
+ is_active_chat_session: session.session_key === activeChatSession,
256
+ }));
257
+ const selectedSession = sessionsWithMeta.find((session) => session.session_key === desiredSelectedSessionKey) ?? null;
258
+ const selectedTasks = selectedSession
259
+ ? taskManager.listBySession(selectedSession.session_key, 12)
260
+ : [];
261
+ const selectedAuditEvents = selectedSession
262
+ ? auditManager.listBySession(selectedSession.session_key, 12)
263
+ : [];
264
+ const selectedMessages = selectedSession?.conversation_id
265
+ ? historyManager.listRecent(selectedSession.conversation_id, 12)
266
+ : [];
267
+ const selectedHistoryPage = selectedSession?.conversation_id
268
+ ? loadHistoryPage(historyManager, selectedSession.conversation_id, historyPageIndex, 20)
269
+ : { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
270
+ const selectedHistorySearchResults = selectedSession?.conversation_id && historySearchQuery.trim()
271
+ ? historyManager.search(historySearchQuery.trim(), { conversationId: selectedSession.conversation_id, limit: 50 })
272
+ : [];
273
+ const unreadTotal = sessionsWithMeta.reduce((sum, session) => sum + (session.unread_count ?? 0), 0);
274
+ const alertSessions = sessionsWithMeta.filter((session) => !!session.binding_alert).length;
275
+ return {
276
+ identity,
277
+ runtimeMode,
278
+ activeChatSession,
279
+ activeChatSessionFile: getActiveSessionFilePath(),
280
+ sessionMapPath: getSessionMapFilePath(),
281
+ sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
282
+ policyPath: policy.path,
283
+ policyDoc: policy.doc,
284
+ sessions: sessionsWithMeta,
285
+ tasks: taskManager.listRecent(30),
286
+ auditEvents: auditManager.listRecent(40),
287
+ selectedSession,
288
+ selectedTasks,
289
+ selectedAuditEvents,
290
+ selectedMessages,
291
+ selectedHistoryPage,
292
+ selectedHistorySearchQuery: historySearchQuery.trim(),
293
+ selectedHistorySearchResults,
294
+ unreadTotal,
295
+ alertSessions,
296
+ };
297
+ } finally {
298
+ store.close();
299
+ }
300
+ }
301
+
302
+ function renderHostTuiScreen(hostState, uiState) {
303
+ const sessions = hostState.sessions || [];
304
+ const selected = hostState.selectedSession || sessions[0] || null;
305
+ const tasks = hostState.selectedTasks || [];
306
+ const auditEvents = hostState.selectedAuditEvents || [];
307
+ const messages = hostState.selectedMessages || [];
308
+ const historyPage = hostState.selectedHistoryPage || { messages: [], pageIndex: 0, hasOlder: false, hasNewer: false };
309
+ const historySearchQuery = hostState.selectedHistorySearchQuery || '';
310
+ const historySearchResults = hostState.selectedHistorySearchResults || [];
311
+ const view = uiState?.view || 'overview';
312
+ const selectedTaskIndex = Math.max(0, Math.min(uiState?.selectedTaskIndex || 0, Math.max(0, tasks.length - 1)));
313
+ const selectedTask = tasks[selectedTaskIndex] || null;
314
+ const statusCountdown = uiState?.statusExpiresAt
315
+ ? ` (${Math.max(0, Math.ceil((uiState.statusExpiresAt - Date.now()) / 1000))}s)`
316
+ : '';
317
+ const statusTs = uiState?.statusAt ? ` @ ${formatStatusTimestamp(uiState.statusAt)}` : '';
318
+ const lines = [
319
+ 'PingAgent Host TUI',
320
+ `DID: ${hostState.identity.did}`,
321
+ `status=${formatStatusLine(uiState?.statusLevel || 'info', uiState?.statusMessage || '(ready)')}${statusTs}${statusCountdown}`,
322
+ `runtime_mode=${hostState.runtimeMode} active_chat_session=${hostState.activeChatSession || '(none)'}`,
323
+ `sessions=${sessions.length} unread_total=${hostState.unreadTotal ?? 0} alert_sessions=${hostState.alertSessions ?? 0} view=${view}`,
324
+ `policy=${hostState.policyPath}`,
325
+ `session_map=${hostState.sessionMapPath}`,
326
+ `binding_alerts=${hostState.sessionBindingAlertsPath}`,
327
+ '',
328
+ ];
329
+
330
+ if (view === 'help') {
331
+ lines.push('Help');
332
+ lines.push('- ↑/↓ or j/k: move selection');
333
+ lines.push('- Enter or l: open detailed session view / task detail');
334
+ lines.push('- Esc or h: back to previous view');
335
+ lines.push('- g / G: jump to first / last session');
336
+ lines.push('- r: refresh now');
337
+ lines.push('- a: approve selected pending contact');
338
+ lines.push('- m: mark selected session as read');
339
+ lines.push('- t: open task list view for selected session');
340
+ lines.push('- x: cancel selected task (in task views)');
341
+ lines.push('- p: multiline reply prompt (detail view)');
342
+ lines.push('- o: open local history paging (detail view)');
343
+ lines.push('- n / p: older / newer history page (history view)');
344
+ lines.push('- s or /: search local history (history view)');
345
+ lines.push('- y: dump task detail to stdout (task-detail view, choose json/plain)');
346
+ lines.push('- b: bind selected conversation to current chat session');
347
+ lines.push('- c: clear selected binding');
348
+ lines.push('- q: quit');
349
+ } else if (view === 'history') {
350
+ lines.push('Conversation History');
351
+ if (!selected) {
352
+ lines.push('No session selected.');
353
+ } else {
354
+ lines.push(`session=${selected.session_key}`);
355
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
356
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
357
+ if (historySearchQuery) {
358
+ lines.push(`search=${historySearchQuery} results=${historySearchResults.length}`);
359
+ lines.push('actions=[s or /] search [n] clear-search [h/Esc] back');
360
+ } else {
361
+ lines.push(`page=${historyPage.pageIndex + 1} has_newer=${historyPage.hasNewer} has_older=${historyPage.hasOlder}`);
362
+ lines.push('actions=[s or /] search [n] older [p] newer [h/Esc] back');
363
+ }
364
+ lines.push('');
365
+ lines.push(...((historySearchQuery ? historySearchResults : historyPage.messages).length
366
+ ? (historySearchQuery ? historySearchResults : historyPage.messages).map((message) => formatMessageRow(message))
367
+ : ['- none']));
368
+ }
369
+ } else if (view === 'task-detail') {
370
+ lines.push('Task Result Detail');
371
+ if (!selected) {
372
+ lines.push('No session selected.');
373
+ } else if (!selectedTask) {
374
+ lines.push('No task selected.');
375
+ } else {
376
+ lines.push(`session=${selected.session_key}`);
377
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
378
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
379
+ lines.push('');
380
+ lines.push(`task=${selectedTask.task_id}`);
381
+ lines.push(`title=${selectedTask.title || '(none)'}`);
382
+ lines.push(`status=${selectedTask.status}`);
383
+ lines.push(`updated_at=${formatTs(selectedTask.updated_at, false)}`);
384
+ if (selectedTask.started_at) lines.push(`started_at=${formatTs(selectedTask.started_at, false)}`);
385
+ lines.push('actions=[x] cancel-task [y] dump-stdout [j/k] switch-task [h/Esc] back-to-tasks');
386
+ lines.push('');
387
+ lines.push('Result');
388
+ lines.push(selectedTask.result_summary || '(none)');
389
+ lines.push('');
390
+ lines.push('Error');
391
+ lines.push(selectedTask.error_message ? `${selectedTask.error_code || 'E_TASK'} ${selectedTask.error_message}` : '(none)');
392
+ lines.push('');
393
+ lines.push('Tasks In Session');
394
+ lines.push(...tasks.map((task, idx) => `${idx === selectedTaskIndex ? '>' : ' '} ${task.task_id} [${task.status}] ${truncateLine(task.title || task.result_summary || '', 70)}`));
395
+ }
396
+ } else if (view === 'tasks') {
397
+ lines.push('Task Detail');
398
+ if (!selected) {
399
+ lines.push('No session selected.');
400
+ } else if (!selectedTask) {
401
+ lines.push('No tasks in the selected session.');
402
+ } else {
403
+ lines.push(`session=${selected.session_key}`);
404
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
405
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
406
+ lines.push('');
407
+ lines.push(`task=${selectedTask.task_id}`);
408
+ lines.push(`title=${selectedTask.title || '(none)'}`);
409
+ lines.push(`status=${selectedTask.status}`);
410
+ lines.push(`updated_at=${formatTs(selectedTask.updated_at, false)}`);
411
+ if (selectedTask.started_at) lines.push(`started_at=${formatTs(selectedTask.started_at, false)}`);
412
+ if (selectedTask.result_summary) lines.push(`result=${selectedTask.result_summary}`);
413
+ if (selectedTask.error_code || selectedTask.error_message) {
414
+ lines.push(`error=${selectedTask.error_code || 'E_TASK'} ${selectedTask.error_message || ''}`.trim());
415
+ }
416
+ lines.push(`actions=[Enter/l] full-detail [x] cancel-task [h/Esc] back [j/k] select-task`);
417
+ lines.push('');
418
+ lines.push('Tasks In Session');
419
+ lines.push(...tasks.map((task, idx) => `${idx === selectedTaskIndex ? '>' : ' '} ${task.task_id} [${task.status}] ${truncateLine(task.title || task.result_summary || '', 70)}`));
420
+ }
421
+ } else {
422
+ lines.push('Sessions');
423
+ lines.push(...(sessions.length
424
+ ? sessions.slice(0, 20).map((session) => formatSessionRow(session, session.session_key === (selected?.session_key || '')))
425
+ : [' (no sessions)']));
426
+ lines.push('');
427
+
428
+ if (selected) {
429
+ lines.push(view === 'detail' ? 'Selected Session Detail' : 'Selected Session');
430
+ lines.push(`session=${selected.session_key}`);
431
+ lines.push(`conversation=${selected.conversation_id || '(none)'}`);
432
+ lines.push(`remote=${selected.remote_did || '(unknown)'}`);
433
+ lines.push(`trust=${selected.trust_state} unread=${selected.unread_count}`);
434
+ lines.push(`last_preview=${selected.last_message_preview || '(none)'}`);
435
+ lines.push(`binding=${selected.binding?.session_key || '(unbound)'}`);
436
+ lines.push(`current_chat=${hostState.activeChatSession || '(none)'}`);
437
+ if (selected.binding_alert) {
438
+ lines.push(`needs_rebind=true`);
439
+ lines.push(`warning=${selected.binding_alert.message}`);
440
+ } else {
441
+ lines.push('needs_rebind=false');
442
+ }
443
+ const actionBar = [
444
+ selected.trust_state === 'pending' ? '[a] approve' : null,
445
+ '[m] mark-read',
446
+ '[p] reply',
447
+ '[o] history',
448
+ '[t] tasks',
449
+ '[b] bind-current',
450
+ '[c] clear-binding',
451
+ ].filter(Boolean).join(' ');
452
+ lines.push(`actions=${actionBar}`);
453
+ lines.push('');
454
+ lines.push('Tasks');
455
+ lines.push(...(tasks.length
456
+ ? tasks.map((task) => `- ${task.title || task.task_id} [${task.status}] ${truncateLine(task.result_summary || task.error_message || '', 80)}`)
457
+ : ['- none']));
458
+ if (view === 'detail') {
459
+ lines.push('');
460
+ lines.push('Recent Messages');
461
+ lines.push(...(messages.length ? messages.map((message) => formatMessageRow(message)) : ['- none']));
462
+ }
463
+ lines.push('');
464
+ lines.push('Audit');
465
+ lines.push(...(auditEvents.length
466
+ ? auditEvents.map((event) => `- ${event.event_type} ${event.action || event.outcome || ''} ${truncateLine(event.explanation || '', 80)}`)
467
+ : ['- none']));
468
+ } else {
469
+ lines.push('No session selected.');
470
+ }
471
+ }
472
+
473
+ lines.push('');
474
+ lines.push('Keys: ↑/↓ or j/k select Enter/l open Esc/h back g/G jump r refresh a approve m read p reply o history s search t tasks x cancel-task y dump b bind c clear ? help q quit');
475
+ return lines.join('\n');
476
+ }
477
+
478
+ async function runHostTui(identityPath, opts) {
479
+ const once = !!opts.once;
480
+ const refreshMs = Math.max(500, Number.parseInt(String(opts.refreshMs || '2000'), 10) || 2000);
481
+
482
+ const uiState = {
483
+ selectedSessionKey: null,
484
+ view: 'overview',
485
+ selectedTaskIndex: 0,
486
+ statusMessage: '(ready)',
487
+ statusLevel: 'info',
488
+ statusExpiresAt: 0,
489
+ statusAt: 0,
490
+ selectedHistoryPageIndex: 0,
491
+ historySearchQuery: '',
492
+ };
493
+
494
+ const render = () => {
495
+ if (uiState.statusExpiresAt && Date.now() >= uiState.statusExpiresAt) {
496
+ uiState.statusMessage = '(ready)';
497
+ uiState.statusLevel = 'info';
498
+ uiState.statusExpiresAt = 0;
499
+ uiState.statusAt = 0;
500
+ }
501
+ const hostState = buildHostState(
502
+ identityPath,
503
+ uiState.selectedSessionKey,
504
+ uiState.selectedHistoryPageIndex,
505
+ uiState.historySearchQuery,
506
+ );
507
+ uiState.selectedSessionKey = hostState.selectedSession?.session_key || hostState.sessions[0]?.session_key || null;
508
+ uiState.selectedHistoryPageIndex = hostState.selectedHistoryPage?.pageIndex || 0;
509
+ const screen = renderHostTuiScreen(hostState, uiState);
510
+ return { hostState, screen };
511
+ };
512
+
513
+ if (once) {
514
+ const { screen } = render();
515
+ console.log(screen);
516
+ return;
517
+ }
518
+
519
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
520
+ console.error('Host TUI requires a TTY. Use --once for a snapshot in non-interactive environments.');
521
+ process.exit(1);
522
+ }
523
+
524
+ let interval = null;
525
+ const redraw = () => {
526
+ const rendered = render();
527
+ clearScreen();
528
+ process.stdout.write(`${rendered.screen}\n`);
529
+ return rendered.hostState;
530
+ };
531
+
532
+ let latestState = redraw();
533
+ readline.emitKeypressEvents(process.stdin);
534
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
535
+
536
+ const cleanup = () => {
537
+ if (interval) clearInterval(interval);
538
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
539
+ process.stdin.pause();
540
+ };
541
+
542
+ const setStatus = (message, level = 'info', ttlMs = 5000) => {
543
+ uiState.statusMessage = truncateLine(message || '(ready)', 160);
544
+ uiState.statusLevel = level;
545
+ uiState.statusExpiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
546
+ uiState.statusAt = Date.now();
547
+ };
548
+
549
+ const promptLine = async (question) => {
550
+ stopInterval();
551
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
552
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
553
+ try {
554
+ const answer = await new Promise((resolve) => {
555
+ rl.question(question, resolve);
556
+ });
557
+ return String(answer ?? '');
558
+ } finally {
559
+ rl.close();
560
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
561
+ startInterval();
562
+ }
563
+ };
564
+
565
+ const promptMultiline = async (question) => {
566
+ stopInterval();
567
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
568
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
569
+ const lines = [];
570
+ try {
571
+ process.stdout.write(`${question}\nFinish with a single "." on its own line.\n`);
572
+ while (true) {
573
+ // eslint-disable-next-line no-await-in-loop
574
+ const line = await new Promise((resolve) => rl.question('> ', resolve));
575
+ const text = String(line ?? '');
576
+ if (text === '.') break;
577
+ lines.push(text);
578
+ }
579
+ return lines.join('\n').trim();
580
+ } finally {
581
+ rl.close();
582
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
583
+ startInterval();
584
+ }
585
+ };
586
+
587
+ const dumpStdoutBlock = async (title, body) => {
588
+ stopInterval();
589
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
590
+ try {
591
+ process.stdout.write(`\n===== ${title} =====\n${body}\n===== end =====\nPress Enter to return...`);
592
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
593
+ await new Promise((resolve) => rl.question('', resolve));
594
+ rl.close();
595
+ } finally {
596
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
597
+ startInterval();
598
+ }
599
+ };
600
+
601
+ const confirmAction = async (question) => {
602
+ const answer = await promptLine(`${question} [y/N] `);
603
+ return answer.trim().toLowerCase() === 'y';
604
+ };
605
+
606
+ const moveSelection = (delta) => {
607
+ if (uiState.view === 'tasks' || uiState.view === 'task-detail') {
608
+ const tasks = latestState.selectedTasks || [];
609
+ if (!tasks.length) return;
610
+ uiState.selectedTaskIndex = Math.min(tasks.length - 1, Math.max(0, uiState.selectedTaskIndex + delta));
611
+ } else if (uiState.view === 'history' && !uiState.historySearchQuery) {
612
+ if (delta > 0 && latestState.selectedHistoryPage?.hasOlder) uiState.selectedHistoryPageIndex += 1;
613
+ if (delta < 0 && latestState.selectedHistoryPage?.hasNewer) uiState.selectedHistoryPageIndex = Math.max(0, uiState.selectedHistoryPageIndex - 1);
614
+ } else {
615
+ const sessions = latestState.sessions || [];
616
+ if (!sessions.length) return;
617
+ const idx = Math.max(0, sessions.findIndex((session) => session.session_key === uiState.selectedSessionKey));
618
+ const next = Math.min(sessions.length - 1, Math.max(0, idx + delta));
619
+ uiState.selectedSessionKey = sessions[next].session_key;
620
+ uiState.selectedTaskIndex = 0;
621
+ uiState.selectedHistoryPageIndex = 0;
622
+ uiState.historySearchQuery = '';
623
+ }
624
+ latestState = redraw();
625
+ };
626
+
627
+ const jumpSelection = (target) => {
628
+ if (uiState.view === 'tasks' || uiState.view === 'task-detail') {
629
+ const tasks = latestState.selectedTasks || [];
630
+ if (!tasks.length) return;
631
+ uiState.selectedTaskIndex = target;
632
+ } else if (uiState.view === 'history') {
633
+ uiState.selectedHistoryPageIndex = 0;
634
+ } else {
635
+ const sessions = latestState.sessions || [];
636
+ if (!sessions.length) return;
637
+ uiState.selectedSessionKey = sessions[target].session_key;
638
+ uiState.selectedTaskIndex = 0;
639
+ uiState.selectedHistoryPageIndex = 0;
640
+ uiState.historySearchQuery = '';
641
+ }
642
+ latestState = redraw();
643
+ };
644
+
645
+ const stopInterval = () => {
646
+ if (interval) clearInterval(interval);
647
+ interval = null;
648
+ };
649
+
650
+ const startInterval = () => {
651
+ stopInterval();
652
+ interval = setInterval(() => {
653
+ latestState = redraw();
654
+ }, refreshMs);
655
+ };
656
+
657
+ startInterval();
658
+
659
+ process.stdin.on('keypress', async (_str, key) => {
660
+ if (key?.name === 'q' || (key?.ctrl && key?.name === 'c')) {
661
+ cleanup();
662
+ process.stdout.write('\n');
663
+ process.exit(0);
664
+ }
665
+ if (key?.name === 'up' || key?.name === 'k') return moveSelection(-1);
666
+ if (key?.name === 'down' || key?.name === 'j') return moveSelection(1);
667
+ if (key?.name === 'g') return jumpSelection(0);
668
+ if (_str === 'G') {
669
+ const max = uiState.view === 'tasks' || uiState.view === 'task-detail'
670
+ ? Math.max(0, (latestState.selectedTasks || []).length - 1)
671
+ : Math.max(0, (latestState.sessions || []).length - 1);
672
+ return jumpSelection(max);
673
+ }
674
+ if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'l') {
675
+ if (uiState.view === 'tasks') {
676
+ uiState.view = 'task-detail';
677
+ } else {
678
+ uiState.view = 'detail';
679
+ }
680
+ latestState = redraw();
681
+ return;
682
+ }
683
+ if (key?.name === 'escape' || key?.name === 'h') {
684
+ if (uiState.view === 'task-detail') uiState.view = 'tasks';
685
+ else if (uiState.view === 'history') uiState.view = 'detail';
686
+ else uiState.view = 'overview';
687
+ latestState = redraw();
688
+ return;
689
+ }
690
+ if (_str === '?') {
691
+ uiState.view = uiState.view === 'help' ? 'overview' : 'help';
692
+ latestState = redraw();
693
+ return;
694
+ }
695
+ if (_str === 'n' && uiState.view === 'history') {
696
+ if (uiState.historySearchQuery) {
697
+ uiState.historySearchQuery = '';
698
+ setStatus('Cleared history search.', 'info');
699
+ latestState = redraw();
700
+ return;
701
+ }
702
+ if (latestState.selectedHistoryPage?.hasOlder) {
703
+ uiState.selectedHistoryPageIndex += 1;
704
+ setStatus(`History page ${uiState.selectedHistoryPageIndex + 1}`, 'info');
705
+ latestState = redraw();
706
+ }
707
+ return;
708
+ }
709
+ if (_str === 'p' && uiState.view === 'history') {
710
+ if (uiState.historySearchQuery) return;
711
+ if (latestState.selectedHistoryPage?.hasNewer) {
712
+ uiState.selectedHistoryPageIndex = Math.max(0, uiState.selectedHistoryPageIndex - 1);
713
+ setStatus(`History page ${uiState.selectedHistoryPageIndex + 1}`, 'info');
714
+ latestState = redraw();
715
+ }
716
+ return;
717
+ }
718
+ if (key?.name === 't') {
719
+ uiState.view = 'tasks';
720
+ uiState.selectedTaskIndex = 0;
721
+ uiState.selectedHistoryPageIndex = 0;
722
+ setStatus('Opened task list view.', 'info');
723
+ latestState = redraw();
724
+ return;
725
+ }
726
+ if (key?.name === 'r') {
727
+ setStatus('Refreshed.', 'info');
728
+ latestState = redraw();
729
+ return;
730
+ }
731
+ if (key?.name === 'm') {
732
+ const selected = latestState.selectedSession;
733
+ if (!selected) return;
734
+ const store = openStore(identityPath);
735
+ try {
736
+ const sessions = new SessionManager(store);
737
+ sessions.markRead(selected.session_key);
738
+ setStatus(`Marked session as read: ${selected.session_key}`, 'ok');
739
+ } finally {
740
+ store.close();
741
+ }
742
+ latestState = redraw();
743
+ return;
744
+ }
745
+ if (key?.name === 'a') {
746
+ const selected = latestState.selectedSession;
747
+ if (!selected?.conversation_id || selected.trust_state !== 'pending') return;
748
+ const confirmed = await confirmAction(`Approve pending conversation ${selected.conversation_id}\nRemote DID: ${selected.remote_did || '(unknown)'}\nProceed?`);
749
+ if (confirmed) {
750
+ const { client, store } = await getClientWithStore(identityPath);
751
+ try {
752
+ const res = await client.approveContact(selected.conversation_id);
753
+ setStatus(res.ok
754
+ ? `Approved conversation ${selected.conversation_id}${res.data?.dm_conversation_id ? ` -> ${res.data.dm_conversation_id}` : ''}`
755
+ : `Approve failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
756
+ } finally {
757
+ store.close();
758
+ }
759
+ } else {
760
+ setStatus('Approve cancelled.', 'warn');
761
+ }
762
+ latestState = redraw();
763
+ return;
764
+ }
765
+ if (key?.name === 'p' && uiState.view === 'detail') {
766
+ const selected = latestState.selectedSession;
767
+ if (!selected?.conversation_id) return;
768
+ const input = await promptMultiline(`Reply to ${selected.remote_did || selected.conversation_id}`);
769
+ const message = input.trim();
770
+ if (!message) {
771
+ setStatus('Reply cancelled.', 'warn');
772
+ latestState = redraw();
773
+ return;
774
+ }
775
+ const { client, store } = await getClientWithStore(identityPath);
776
+ try {
777
+ const res = await client.sendMessage(selected.conversation_id, SCHEMA_TEXT, { text: message });
778
+ setStatus(res.ok
779
+ ? `Sent reply to ${selected.remote_did || selected.conversation_id} (${res.data?.message_id || 'queued'})`
780
+ : `Reply failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
781
+ } finally {
782
+ store.close();
783
+ }
784
+ latestState = redraw();
785
+ return;
786
+ }
787
+ if (key?.name === 'o' && uiState.view === 'detail') {
788
+ uiState.view = 'history';
789
+ uiState.selectedHistoryPageIndex = 0;
790
+ uiState.historySearchQuery = '';
791
+ setStatus('Opened local history view.', 'info');
792
+ latestState = redraw();
793
+ return;
794
+ }
795
+ if ((_str === '/' || key?.name === 's') && uiState.view === 'history') {
796
+ const answer = await promptLine(`Search history (${latestState.selectedSession?.conversation_id || 'current conversation'}). Empty clears search: `);
797
+ const query = answer.trim();
798
+ uiState.historySearchQuery = query;
799
+ uiState.selectedHistoryPageIndex = 0;
800
+ setStatus(query ? `History search: ${query}` : 'Cleared history search.', 'info');
801
+ latestState = redraw();
802
+ return;
803
+ }
804
+ if (key?.name === 'b') {
805
+ const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
806
+ if (!selected?.conversation_id) return;
807
+ const current = latestState.activeChatSession || '(none)';
808
+ const previous = selected.binding?.session_key || '(unbound)';
809
+ const confirmed = await confirmAction(`Rebind conversation ${selected.conversation_id}\nRemote DID: ${selected.remote_did || '(unknown)'}\nCurrent chat: ${current}\nPrevious binding: ${previous}\nProceed?`);
810
+ if (confirmed) {
811
+ if (!latestState.activeChatSession) {
812
+ setStatus('Rebind failed: no active chat session.', 'err');
813
+ latestState = redraw();
814
+ return;
815
+ }
816
+ setSessionBinding(selected.conversation_id, latestState.activeChatSession);
817
+ setStatus(`Rebound ${selected.conversation_id} -> ${latestState.activeChatSession}`, 'ok');
818
+ } else {
819
+ setStatus('Rebind cancelled.', 'warn');
820
+ }
821
+ latestState = redraw();
822
+ return;
823
+ }
824
+ if (key?.name === 'c') {
825
+ const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
826
+ if (!selected?.conversation_id) return;
827
+ removeSessionBinding(selected.conversation_id);
828
+ setStatus(`Cleared binding for ${selected.conversation_id}`, 'ok');
829
+ latestState = redraw();
830
+ return;
831
+ }
832
+ if (key?.name === 'x' && (uiState.view === 'tasks' || uiState.view === 'task-detail')) {
833
+ const selected = latestState.selectedSession;
834
+ const tasks = latestState.selectedTasks || [];
835
+ const task = tasks[Math.max(0, Math.min(uiState.selectedTaskIndex, Math.max(0, tasks.length - 1)))] || null;
836
+ if (!selected?.conversation_id || !task) return;
837
+ const confirmed = await confirmAction(`Cancel task ${task.task_id}\nTitle: ${task.title || '(none)'}\nStatus: ${task.status}\nProceed?`);
838
+ if (confirmed) {
839
+ const { client, store } = await getClientWithStore(identityPath);
840
+ try {
841
+ const res = await client.cancelTask(selected.conversation_id, task.task_id);
842
+ setStatus(res.ok
843
+ ? `Cancel requested for task ${task.task_id}`
844
+ : `Cancel failed: ${res.error?.message || 'unknown error'}`, res.ok ? 'ok' : 'err');
845
+ } finally {
846
+ store.close();
847
+ }
848
+ } else {
849
+ setStatus('Cancel task aborted.', 'warn');
850
+ }
851
+ latestState = redraw();
852
+ return;
853
+ }
854
+ if (key?.name === 'y' && uiState.view === 'task-detail') {
855
+ const tasks = latestState.selectedTasks || [];
856
+ const task = tasks[Math.max(0, Math.min(uiState.selectedTaskIndex, Math.max(0, tasks.length - 1)))] || null;
857
+ if (!task) return;
858
+ const formatAnswer = await promptLine('Export format [j]son / [p]lain (default plain): ');
859
+ const format = formatAnswer.trim().toLowerCase().startsWith('j') ? 'json' : 'plain';
860
+ await dumpStdoutBlock(`task ${task.task_id} (${format})`, buildTaskExportBody(task, format));
861
+ setStatus(`Dumped task ${task.task_id} to stdout as ${format}.`, 'ok');
862
+ latestState = redraw();
863
+ return;
864
+ }
865
+ });
866
+ }
867
+
80
868
  /** Format timestamp for display: human-readable by default, raw ms when --raw. */
81
869
  function formatTs(tsMs, raw) {
82
870
  if (raw) return String(tsMs);
@@ -94,6 +882,19 @@ function makeClient(id, identityPath) {
94
882
  });
95
883
  }
96
884
 
885
+ function makeClientWithStore(id, identityPath) {
886
+ const p = identityPath ?? getEffectiveIdentityPath();
887
+ const store = openStore(p);
888
+ const client = new PingAgentClient({
889
+ serverUrl: id.serverUrl ?? DEFAULT_SERVER,
890
+ identity: id,
891
+ accessToken: id.accessToken ?? '',
892
+ onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, p),
893
+ store,
894
+ });
895
+ return { client, store };
896
+ }
897
+
97
898
  /** Load identity, proactively refresh token if near/past expiry, then return client. */
98
899
  async function getClient(identityPath) {
99
900
  const p = identityPath ?? getEffectiveIdentityPath();
@@ -105,6 +906,16 @@ async function getClient(identityPath) {
105
906
  return client;
106
907
  }
107
908
 
909
+ async function getClientWithStore(identityPath) {
910
+ const p = identityPath ?? getEffectiveIdentityPath();
911
+ let id = loadIdentity(p);
912
+ await ensureTokenValid(p, id.serverUrl);
913
+ id = loadIdentity(p);
914
+ const { client, store } = makeClientWithStore(id, p);
915
+ await client.ensureEncryptionKeyPublished().catch(() => undefined);
916
+ return { client, store };
917
+ }
918
+
108
919
  const program = new Command();
109
920
  program
110
921
  .name('pingagent')
@@ -1438,6 +2249,25 @@ billing
1438
2249
  }
1439
2250
  });
1440
2251
 
2252
+ const host = program
2253
+ .command('host')
2254
+ .description('Headless runtime inspection and control for PingAgent host state');
2255
+
2256
+ host
2257
+ .command('tui')
2258
+ .description('Start a terminal UI for runtime, sessions, bindings, and rebind actions')
2259
+ .option('--once', 'Print one snapshot and exit')
2260
+ .option('--refresh-ms <ms>', 'Refresh interval in interactive mode', '2000')
2261
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
2262
+ .action(async (opts) => {
2263
+ const identityPath = getIdentityPathForCommand(opts);
2264
+ if (!identityExists(identityPath)) {
2265
+ console.error('No identity found. Run: pingagent init');
2266
+ process.exit(1);
2267
+ }
2268
+ await runHostTui(identityPath, opts);
2269
+ });
2270
+
1441
2271
  program
1442
2272
  .command('web')
1443
2273
  .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.')