@tyvm/knowhow 0.0.148 โ†’ 0.0.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +18 -9
  3. package/src/agents/tools/startAgentTask.ts +5 -2
  4. package/src/auth/keyAuth.ts +10 -4
  5. package/src/auth/registerIdentity.ts +67 -0
  6. package/src/chat/modules/AgentModule.ts +39 -7
  7. package/src/clients/index.ts +34 -1
  8. package/src/commands/agent.ts +1 -0
  9. package/src/commands/agents.ts +4 -1
  10. package/src/commands/misc.ts +17 -2
  11. package/src/commands/processes.ts +116 -6
  12. package/src/hashes.ts +2 -0
  13. package/src/processes/ProcessManager.ts +2 -1
  14. package/src/processes/managedProcessShim.ts +17 -5
  15. package/src/processors/Base64ImageDetector.ts +28 -4
  16. package/src/processors/JsonCompressor.ts +14 -0
  17. package/src/processors/TokenCompressor.ts +17 -14
  18. package/src/services/AgentService.ts +2 -2
  19. package/src/services/AgentSyncFs.ts +42 -0
  20. package/src/services/modules/computerUse.ts +3 -1
  21. package/tests/clients/AIClient.test.ts +98 -15
  22. package/tests/processes/managedProcessShim.test.ts +59 -0
  23. package/tests/processors/Base64ImageDetector.test.ts +21 -0
  24. package/tests/processors/TokenCompressor.test.ts +18 -0
  25. package/tests/unit/auth/registerIdentity.test.ts +93 -0
  26. package/tests/unit/hashes.test.ts +23 -0
  27. package/ts_build/package.json +1 -1
  28. package/ts_build/src/agents/base/base.js +14 -9
  29. package/ts_build/src/agents/base/base.js.map +1 -1
  30. package/ts_build/src/agents/tools/startAgentTask.js +2 -2
  31. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  32. package/ts_build/src/auth/keyAuth.js +10 -3
  33. package/ts_build/src/auth/keyAuth.js.map +1 -1
  34. package/ts_build/src/auth/registerIdentity.d.ts +7 -0
  35. package/ts_build/src/auth/registerIdentity.js +62 -0
  36. package/ts_build/src/auth/registerIdentity.js.map +1 -0
  37. package/ts_build/src/chat/modules/AgentModule.d.ts +2 -0
  38. package/ts_build/src/chat/modules/AgentModule.js +27 -7
  39. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  40. package/ts_build/src/clients/index.js +19 -1
  41. package/ts_build/src/clients/index.js.map +1 -1
  42. package/ts_build/src/commands/agent.js +1 -0
  43. package/ts_build/src/commands/agent.js.map +1 -1
  44. package/ts_build/src/commands/agents.js +1 -1
  45. package/ts_build/src/commands/agents.js.map +1 -1
  46. package/ts_build/src/commands/misc.js +12 -1
  47. package/ts_build/src/commands/misc.js.map +1 -1
  48. package/ts_build/src/commands/processes.js +125 -6
  49. package/ts_build/src/commands/processes.js.map +1 -1
  50. package/ts_build/src/hashes.js +1 -0
  51. package/ts_build/src/hashes.js.map +1 -1
  52. package/ts_build/src/processes/ProcessManager.js +2 -1
  53. package/ts_build/src/processes/ProcessManager.js.map +1 -1
  54. package/ts_build/src/processes/managedProcessShim.js +13 -7
  55. package/ts_build/src/processes/managedProcessShim.js.map +1 -1
  56. package/ts_build/src/processors/Base64ImageDetector.d.ts +1 -0
  57. package/ts_build/src/processors/Base64ImageDetector.js +19 -2
  58. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  59. package/ts_build/src/processors/JsonCompressor.js +7 -0
  60. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  61. package/ts_build/src/processors/TokenCompressor.d.ts +1 -1
  62. package/ts_build/src/processors/TokenCompressor.js +9 -8
  63. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  64. package/ts_build/src/services/AgentService.d.ts +1 -1
  65. package/ts_build/src/services/AgentService.js.map +1 -1
  66. package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
  67. package/ts_build/src/services/AgentSyncFs.js +34 -0
  68. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  69. package/ts_build/src/services/modules/computerUse.d.ts +3 -1
  70. package/ts_build/tests/clients/AIClient.test.js +73 -11
  71. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  72. package/ts_build/tests/processes/managedProcessShim.test.d.ts +1 -0
  73. package/ts_build/tests/processes/managedProcessShim.test.js +92 -0
  74. package/ts_build/tests/processes/managedProcessShim.test.js.map +1 -0
  75. package/ts_build/tests/processors/Base64ImageDetector.test.js +18 -0
  76. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
  77. package/ts_build/tests/processors/TokenCompressor.test.js +15 -0
  78. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  79. package/ts_build/tests/unit/auth/registerIdentity.test.d.ts +1 -0
  80. package/ts_build/tests/unit/auth/registerIdentity.test.js +86 -0
  81. package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -0
  82. package/ts_build/tests/unit/hashes.test.d.ts +1 -0
  83. package/ts_build/tests/unit/hashes.test.js +25 -0
  84. package/ts_build/tests/unit/hashes.test.js.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.148",
3
+ "version": "0.0.150",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -1670,8 +1670,7 @@ export abstract class BaseAgent implements IAgent {
1670
1670
  const response = await this.createAgentCompletion({
1671
1671
  model,
1672
1672
  messages: taskBreakdownMessages,
1673
- tools: this.getEnabledTools(),
1674
- tool_choice: "auto",
1673
+ tool_choice: "none",
1675
1674
  long_ttl_cache: this.runTime() > 300_000,
1676
1675
  ...(this.reasoningEffort !== undefined && {
1677
1676
  reasoning_effort: this.reasoningEffort,
@@ -1681,9 +1680,17 @@ export abstract class BaseAgent implements IAgent {
1681
1680
  }),
1682
1681
  });
1683
1682
 
1684
- this.taskBreakdown = response.choices[0].message.content;
1683
+ // The model may return null content (e.g. when it responds with tool calls
1684
+ // instead of text). Guard against storing a literal null so that template
1685
+ // strings don't render "null".
1686
+ const breakdownContent = response.choices
1687
+ .map((c) => c.message.content)
1688
+ .filter((s): s is string => typeof s === "string" && s.trim().length > 0)
1689
+ .join("\n\n");
1690
+
1691
+ this.taskBreakdown = breakdownContent || "";
1685
1692
  this.log(`task breakdown cost: ${response.usd_cost}`);
1686
- return this.taskBreakdown;
1693
+ return this.taskBreakdown || undefined;
1687
1694
  }
1688
1695
 
1689
1696
  async compressMessages(messages: Message[]) {
@@ -1713,7 +1720,7 @@ export abstract class BaseAgent implements IAgent {
1713
1720
  3. Next Steps - what we're about to do next to continue the user's original request.
1714
1721
  4. Tasks remaining - what tasks are left from the initial task breakdown.
1715
1722
 
1716
- Our initial task breakdown: ${this.taskBreakdown}
1723
+ ${this.taskBreakdown ? `Our initial task breakdown: ${this.taskBreakdown}` : ""}
1717
1724
 
1718
1725
  This summary will replace the older history. The latest agent interaction will remain verbatim:
1719
1726
 
@@ -1729,20 +1736,22 @@ export abstract class BaseAgent implements IAgent {
1729
1736
 
1730
1737
  const response = await this.createAgentCompletion({
1731
1738
  messages: compressMessagesPayload,
1739
+ tool_choice: "none",
1732
1740
  });
1733
1741
 
1734
- const summaries = response.choices.map((c) => c.message.content);
1742
+ const summaries = response.choices
1743
+ .map((c) => c.message.content)
1744
+ .filter((s): s is string => typeof s === "string" && s.trim().length > 0);
1735
1745
  this.summaries.push(...summaries);
1736
1746
 
1737
1747
  const startMessages = [
1738
1748
  {
1739
1749
  role: "user",
1740
1750
  content: `
1741
- Initial task breakdown:
1742
- ${this.taskBreakdown}
1751
+ ${this.taskBreakdown ? `Initial task breakdown:\n ${this.taskBreakdown}` : "(No task breakdown available โ€” summarize what you know from context above)"}
1743
1752
 
1744
1753
  We have just compressed the conversation to save memory:
1745
- ${JSON.stringify(summaries)}
1754
+ ${summaries.length > 0 ? JSON.stringify(summaries) : "(summary unavailable โ€” please continue from the task breakdown above)"}
1746
1755
 
1747
1756
  Please continue the task from where we left off
1748
1757
  `,
@@ -152,7 +152,9 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<Star
152
152
  // Default filesystem synchronization ON unless the caller explicitly opts out
153
153
  // (syncFs: false) or is using a messageId-based web sync. This ensures spawned
154
154
  // subagents always appear in `knowhow agents list` and can be attached/tailed.
155
- const useSyncFs = syncFs !== false && !messageId;
155
+ // If the caller explicitly passes syncFs: true, honor it even when a messageId
156
+ // is also provided (messageId handles remote sync; syncFs handles local fs sync).
157
+ const useSyncFs = syncFs === true ? true : (syncFs !== false && !messageId);
156
158
 
157
159
  // Use provided taskId if given, otherwise generate one from the prompt
158
160
  const taskId = providedTaskId ?? generateTaskId(prompt);
@@ -175,7 +177,8 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<Star
175
177
 
176
178
  if (messageId) {
177
179
  args.push("--message-id", messageId);
178
- } else if (useSyncFs) {
180
+ }
181
+ if (useSyncFs) {
179
182
  args.push("--sync-fs");
180
183
  }
181
184
  // When syncRemote is requested, pass it through so the spawned agent pushes
@@ -81,7 +81,7 @@ export async function authenticateWithKey(
81
81
  }
82
82
  }
83
83
 
84
- /** Register an Ed25519 CLI-auth key using the JWT obtained by browser PKCE. */
84
+ /** Register an Ed25519 identity; the backend derives its scope from the source JWT. */
85
85
  export async function registerPublicKey(
86
86
  jwt: string,
87
87
  publicKeyBase64: string,
@@ -109,7 +109,13 @@ export async function registerPublicKey(
109
109
 
110
110
  export function storeJwt(jwt: string): void {
111
111
  const directory = path.dirname(JWT_FILE_PATH);
112
- fs.mkdirSync(directory, { recursive: true });
113
- fs.writeFileSync(JWT_FILE_PATH, jwt, { mode: 0o600 });
114
- fs.chmodSync(JWT_FILE_PATH, 0o600);
112
+ const temporaryPath = `${JWT_FILE_PATH}.${process.pid}.tmp`;
113
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
114
+ try {
115
+ fs.writeFileSync(temporaryPath, jwt, { mode: 0o600, flag: "wx" });
116
+ fs.renameSync(temporaryPath, JWT_FILE_PATH);
117
+ fs.chmodSync(JWT_FILE_PATH, 0o600);
118
+ } finally {
119
+ fs.rmSync(temporaryPath, { force: true });
120
+ }
115
121
  }
@@ -0,0 +1,67 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getOrCreatePublicKey } from "./keyManager";
4
+ import { registerPublicKey, exchangePublicKeyJwt, storeJwt } from "./keyAuth";
5
+ import { getConfig, updateConfig } from "../config";
6
+
7
+ export interface RegisterIdentityOptions {
8
+ /** Optional Ed25519 private-key path. Defaults to ~/.knowhow/keys/id_ed25519. */
9
+ identityPath?: string;
10
+ /** API base URL. Defaults to KNOWHOW_API_URL or the production public API. */
11
+ apiUrl?: string;
12
+ }
13
+
14
+ /** Decode an unverified JWT payload for local routing information only. */
15
+ export function decodeJwtPayload(jwt: string): Record<string, unknown> {
16
+ const parts = jwt.split(".");
17
+ if (parts.length !== 3) throw new Error("Invalid JWT format: expected 3 parts");
18
+ const base64 = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
19
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
20
+ try {
21
+ const payload: unknown = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
22
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error();
23
+ return payload as Record<string, unknown>;
24
+ } catch {
25
+ throw new Error("Invalid JWT payload: could not parse JSON");
26
+ }
27
+ }
28
+
29
+ export function extractOrgIdFromJwt(jwt: string): string {
30
+ const payload = decodeJwtPayload(jwt);
31
+ const orgId = payload["org"] ?? payload["orgId"] ?? payload["org_id"] ?? payload["organizationId"];
32
+ if (typeof orgId !== "string" || !orgId) {
33
+ throw new Error("JWT does not contain an organization claim (expected org, orgId, org_id, or organizationId)");
34
+ }
35
+ return orgId;
36
+ }
37
+
38
+ /**
39
+ * Register a CLI identity using the JWT already stored in the current project's
40
+ * .knowhow/.jwt, then replace that JWT with one obtained via key authentication.
41
+ * This is intentionally generic: authorization scope is derived by the backend
42
+ * from the verified source JWT.
43
+ */
44
+ export async function registerIdentity(options: RegisterIdentityOptions = {}): Promise<void> {
45
+ const apiUrl = options.apiUrl ?? process.env.KNOWHOW_API_URL ?? "https://api.knowhow.tyvm.ai";
46
+ const jwtPath = path.join(process.cwd(), ".knowhow", ".jwt");
47
+ if (!fs.existsSync(jwtPath)) throw new Error(`JWT file not found: ${jwtPath}`);
48
+ const sourceJwt = fs.readFileSync(jwtPath, "utf8").trim();
49
+ if (!sourceJwt) throw new Error(`JWT file is empty: ${jwtPath}`);
50
+
51
+ const orgId = extractOrgIdFromJwt(sourceJwt);
52
+ const keyPair = getOrCreatePublicKey(options.identityPath);
53
+ await registerPublicKey(sourceJwt, keyPair.publicKeyBase64, apiUrl);
54
+ const identityJwt = await exchangePublicKeyJwt(orgId, apiUrl, options.identityPath);
55
+ storeJwt(identityJwt);
56
+
57
+ const config = await getConfig();
58
+ if (!config.modelProviders) config.modelProviders = [];
59
+ if (!config.modelProviders.some((provider: { provider: string }) => provider.provider === "knowhow")) {
60
+ config.modelProviders.push({ provider: "knowhow" });
61
+ }
62
+ config.orgId = orgId;
63
+ config.cliIdentityPath = path.resolve(options.identityPath ?? keyPair.privateKeyPath);
64
+ await updateConfig(config);
65
+
66
+ console.log(`โœ… Identity registered. Identity: ${keyPair.fingerprint}, Org: ${orgId}`);
67
+ }
@@ -8,6 +8,7 @@ import {
8
8
  TaskRegistry,
9
9
  SyncedAgentWatcher,
10
10
  SyncerService,
11
+ AgentService,
11
12
  } from "../../services/index";
12
13
  import { AttachableAgent } from "../../services/SyncedAgentWatcher";
13
14
  import * as fs from "fs";
@@ -77,10 +78,15 @@ export class AgentModule extends BaseChatModule {
77
78
  | { toolCall?: string; toolUsed?: string; agentSay?: string; done: string }
78
79
  | undefined;
79
80
 
81
+ private agentService: AgentService;
82
+ private eventService: EventService;
83
+
80
84
  constructor() {
81
85
  super();
82
86
  this.taskRegistry = new TaskRegistry();
83
87
  this.sessionManager = new SessionManager();
88
+ this.agentService = services().Agents;
89
+ this.eventService = services().Events;
84
90
  }
85
91
 
86
92
  /**
@@ -327,7 +333,7 @@ export class AgentModule extends BaseChatModule {
327
333
  await super.initialize(service);
328
334
 
329
335
  // Set up plugin log event handler - use setListener so re-init doesn't double-subscribe
330
- const Events = services().Events;
336
+ const Events = this.eventService;
331
337
  Events.setListener(
332
338
  { key: "agentModule:pluginLog", event: Events.eventTypes.pluginLog },
333
339
  (logEvent: any) => {
@@ -405,7 +411,7 @@ export class AgentModule extends BaseChatModule {
405
411
  // Set selected agent in context and enable agent mode
406
412
  if (context) {
407
413
  // Create a temporary agent instance to read its default model/provider
408
- const agentContext = services().Agents.getAgentContext();
414
+ const agentContext = this.agentService.getAgentContext();
409
415
  const tempAgent = createAgent(
410
416
  agentName as AgentName,
411
417
  agentContext
@@ -427,6 +433,25 @@ export class AgentModule extends BaseChatModule {
427
433
  this.chatService.setMode("agent");
428
434
  }
429
435
 
436
+ console.log(
437
+ `Agent mode enabled. Selected agent: ${agentName}. Type your task to get started.`
438
+ );
439
+ } else if (this.agentService.listAgents().includes(agentName)) {
440
+ // Config agent from knowhow.json
441
+ if (context) {
442
+ const configAgent = this.agentService.getAgent<BaseAgent>(agentName);
443
+ context.selectedAgent = configAgent;
444
+ context.agentMode = true;
445
+ context.currentAgent = agentName;
446
+ const clientInfo = await configAgent.clientService.getClient(
447
+ undefined,
448
+ configAgent.getModel()
449
+ );
450
+ context.currentModel = clientInfo.model;
451
+ context.currentProvider = clientInfo.provider;
452
+
453
+ this.chatService.setMode("agent");
454
+ }
430
455
  console.log(
431
456
  `Agent mode enabled. Selected agent: ${agentName}. Type your task to get started.`
432
457
  );
@@ -601,12 +626,16 @@ export class AgentModule extends BaseChatModule {
601
626
  */
602
627
  async handleAgentsCommand(args: string[]): Promise<void> {
603
628
  try {
604
- const agentNames = Object.keys(agentConstructors);
629
+ const builtinNames = Object.keys(agentConstructors);
630
+ const configAgentNames = this.agentService.listAgents();
631
+ // Merge, deduplicate, preserving built-ins first
632
+ const agentNames = [...new Set([...builtinNames, ...configAgentNames])];
605
633
 
606
634
  if (agentNames.length > 0) {
607
635
  console.log("\nAvailable agents:");
608
636
  agentNames.forEach((name) => {
609
- console.log(` - ${name}`);
637
+ const isConfig = !builtinNames.includes(name);
638
+ console.log(` - ${name}${isConfig ? " (config)" : ""}`);
610
639
  });
611
640
  console.log("โ”€".repeat(80), "\n");
612
641
 
@@ -700,11 +729,14 @@ export class AgentModule extends BaseChatModule {
700
729
  const agentName = session.agentName || context.currentAgent;
701
730
  const previousAgentMode = context?.agentMode;
702
731
 
703
- if (!agentName || !agentConstructors[agentName as AgentName]) {
732
+ if (!agentName) {
704
733
  console.error(`Agent ${agentName} not found.`);
705
734
  return;
706
735
  }
707
736
 
737
+ // setupAgent resolves both built-in agents and custom ConfigAgents. Do
738
+ // not reject config-defined agents here before it gets that opportunity.
739
+
708
740
  // Start agent with Knowhow task context and restored message history
709
741
  const remoteTaskId = asRemoteTaskId(session.knowhowTaskId);
710
742
  const { agent, taskId } = await this.setupAgent({
@@ -817,7 +849,7 @@ export class AgentModule extends BaseChatModule {
817
849
  enabledTools?: string[];
818
850
  }) {
819
851
  const { input, chatHistory = [], agentName } = options;
820
- const agentContext = services().Agents.getAgentContext();
852
+ const agentContext = this.agentService.getAgentContext();
821
853
 
822
854
  // Resolve agent: built-in agents first, then fall back to ConfigAgents from knowhow.json
823
855
  let agent: BaseAgent;
@@ -826,7 +858,7 @@ export class AgentModule extends BaseChatModule {
826
858
  } else {
827
859
  // Try to find a ConfigAgent registered via loadAgentsFromConfig (from knowhow.json agents[])
828
860
  try {
829
- const configAgent = services().Agents.getAgent(options.agentName);
861
+ const configAgent = this.agentService.getAgent(options.agentName);
830
862
  agent = configAgent as unknown as BaseAgent;
831
863
  } catch {
832
864
  throw new Error(
@@ -544,14 +544,17 @@ export class AIClient {
544
544
  }
545
545
 
546
546
  // Handle the case when model prefix is gpt-5 and the provider is knowhow, and the actual model is openai/gpt-5
547
+ // Also handles deep prefixes like "accounts/fireworks/models/kimi-k3" matched by "kimi-k3"
547
548
  const inferredFound = models.find((m) => {
548
549
  const split = m.split("/");
549
550
  if (split.length < 2) return false;
550
551
  const inferredModel = split.slice(1).join("/");
552
+ const lastSegment = split[split.length - 1];
551
553
  return (
552
554
  m === modelPrefix ||
553
555
  inferredModel === modelPrefix ||
554
- inferredModel.startsWith(modelPrefix)
556
+ inferredModel.startsWith(modelPrefix) ||
557
+ lastSegment === modelPrefix
555
558
  );
556
559
  });
557
560
  if (inferredFound) {
@@ -626,6 +629,36 @@ export class AIClient {
626
629
  return { provider, model };
627
630
  }
628
631
 
632
+ // When an explicit provider is given and the model contains slashes (e.g.
633
+ // provider="knowhow", model="anthropic/claude-sonnet-4-6"), check if the
634
+ // explicit provider has a model that matches by ignoring the leading
635
+ // provider prefix segments. Also handle deep prefixes like
636
+ // "accounts/fireworks/models/kimi-k3" โ†’ search for "kimi-k3".
637
+ if (provider && model) {
638
+ const providerModels = this.clientModels[provider] ?? [];
639
+ if (model.includes("/")) {
640
+ // Model has slashes: try progressively stripping leading prefix segments
641
+ const modelParts = model.split("/");
642
+ for (let i = 1; i < modelParts.length; i++) {
643
+ const suffix = modelParts.slice(i).join("/");
644
+ // Exact match within the explicit provider
645
+ if (this.providerHasModel(provider, suffix)) {
646
+ return { provider, model: suffix };
647
+ }
648
+ }
649
+ }
650
+ // For any model (with or without slashes): check if a registered model's
651
+ // last path segment matches the requested model name.
652
+ // e.g. provider="fireworks", model="kimi-k3" matches
653
+ // "accounts/fireworks/models/kimi-k3"
654
+ const lastSegmentMatch = providerModels.find(
655
+ (m) => m.split("/").pop() === model
656
+ );
657
+ if (lastSegmentMatch) {
658
+ return { provider, model: lastSegmentMatch };
659
+ }
660
+ }
661
+
629
662
  // If an explicit provider was given, don't fall through to fuzzy cross-provider
630
663
  // search โ€” that would silently pick a completely different provider (e.g. nvidia
631
664
  // instead of fireworks). Just pass through and let the API accept/reject the model.
@@ -193,6 +193,7 @@ export function addAgentCommand(program: Command, getChatService: () => any): vo
193
193
 
194
194
  const { taskCompleted } = await agentModule.setupAgent({
195
195
  ...options,
196
+ agentName: options.agentName ?? config.chat?.defaultAgent ?? "Patcher",
196
197
  input,
197
198
  maxTimeLimit: parseInt(options.maxTimeLimit, 10),
198
199
  maxSpendLimit: parseFloat(options.maxSpendLimit),
@@ -292,12 +292,15 @@ function buildRows(includeAll: boolean): Row[] {
292
292
  * - A numeric index string ("1", "2", โ€ฆ) โ€” 1-based row from `agents list`
293
293
  * - An exact task ID
294
294
  * - A partial task ID (substring match)
295
+ *
296
+ * NOTE: numeric index always uses the same row set as `agents list` (includeAll=false)
297
+ * so that index numbers are consistent across all subcommands.
295
298
  */
296
299
  function resolveTaskId(value: string, includeAll = false): string | null {
297
300
  const n = parseInt(value, 10);
298
301
  // Only treat as a numeric index if it's a small number (โ‰ค 9999) โ€” large numbers are timestamp-based task IDs
299
302
  if (!isNaN(n) && String(n) === value.trim() && n <= 9999) {
300
- const rows = buildRows(includeAll);
303
+ const rows = buildRows(false);
301
304
  const row = rows[n - 1];
302
305
  return row ? row.taskId : null;
303
306
  }
@@ -6,6 +6,7 @@ import { embed, upload, download, purge } from "../index";
6
6
  import { generate, buildWaves, normalizeInputPattern, GenerateOptions } from "../generate";
7
7
  import { init } from "../config";
8
8
  import { login } from "../login";
9
+ import { registerIdentity } from "../auth/registerIdentity";
9
10
  import { KnowhowSimpleClient } from "../services/KnowhowClient";
10
11
  import { startChat } from "../chat";
11
12
 
@@ -24,8 +25,22 @@ export function addLoginCommand(program: Command): void {
24
25
  .description("Login to knowhow")
25
26
  .option("--jwt", "Use manual JWT input instead of browser login")
26
27
  .option("-i, --identity <path>", "Ed25519 private key to use for public-key login")
27
- .action(async (options: { jwt?: boolean; identity?: string }) => {
28
- await login(options.jwt, options.identity);
28
+ .option("--register-identity", "Register an Ed25519 identity using the JWT in .knowhow/.jwt")
29
+ .option("--api-url <url>", "API base URL override (falls back to KNOWHOW_API_URL env)")
30
+ .action(async (options: {
31
+ jwt?: boolean;
32
+ identity?: string;
33
+ registerIdentity?: boolean;
34
+ apiUrl?: string;
35
+ }) => {
36
+ if (options.registerIdentity) {
37
+ await registerIdentity({
38
+ identityPath: options.identity,
39
+ apiUrl: options.apiUrl,
40
+ });
41
+ } else {
42
+ await login(options.jwt, options.identity);
43
+ }
29
44
  });
30
45
  }
31
46
 
@@ -8,8 +8,23 @@ import {
8
8
  import { runManagedProcessShim } from "../processes/managedProcessShim";
9
9
 
10
10
  const terminal = new Set(["exited", "failed", "stopped"]);
11
- function resolveProcess(value: string): ManagedProcess {
11
+ function resolveProcess(value?: string, index?: string, options: any = {}): ManagedProcess {
12
12
  const root = getProcessesDir();
13
+ if (index !== undefined) {
14
+ const parsed = Number(index);
15
+ if (!Number.isInteger(parsed) || parsed < 0) {
16
+ throw new Error(`Invalid process index '${index}'. Use the index shown by 'knowhow processes list'.`);
17
+ }
18
+ const items = filtered(options);
19
+ const item = items[parsed];
20
+ if (!item) {
21
+ throw new Error(`Managed process index ${parsed} not found. Run 'knowhow processes list${options.all ? " --all" : ""}' to see valid indices.`);
22
+ }
23
+ return new ManagedProcess(item.id, item.directory);
24
+ }
25
+ if (!value) {
26
+ throw new Error("Provide a process id, or use -i <index> from 'knowhow processes list'.");
27
+ }
13
28
  const matches = listManagedProcesses(root).filter((p) => p.id === value || p.id.includes(value));
14
29
  if (!matches.length) throw new Error(`Managed process not found: ${value}`);
15
30
  const exact = matches.find((p) => p.id === value);
@@ -30,13 +45,55 @@ function filtered(options: any): ManagedProcessInfo[] {
30
45
  return true;
31
46
  });
32
47
  }
48
+ function truncate(value: string, width: number): string {
49
+ if (value.length <= width) return value;
50
+ if (width <= 1) return "โ€ฆ";
51
+ return `${value.slice(0, width - 1)}โ€ฆ`;
52
+ }
53
+ function truncateMiddle(value: string, width: number): string {
54
+ if (value.length <= width) return value;
55
+ if (width <= 1) return "โ€ฆ";
56
+ const left = Math.ceil((width - 1) / 2);
57
+ return `${value.slice(0, left)}โ€ฆ${value.slice(value.length - (width - left - 1))}`;
58
+ }
59
+ function printProcessTable(rows: Array<Record<string, string | number>>): void {
60
+ const terminalWidth = process.stdout.columns || 120;
61
+ const available = Math.max(80, Math.min(terminalWidth, 180));
62
+ const widths = {
63
+ index: Math.max(1, String(rows.length - 1).length),
64
+ state: Math.max(5, ...rows.map((row) => String(row.state).length)),
65
+ pid: Math.max(3, ...rows.map((row) => String(row.pid).length)),
66
+ background: 2,
67
+ id: Math.min(38, Math.max(12, ...rows.map((row) => String(row.id).length))),
68
+ parent: Math.min(20, Math.max(6, ...rows.map((row) => String(row.parent).length))),
69
+ };
70
+ const fixedWidth = widths.index + widths.state + widths.pid + widths.background
71
+ + widths.id + widths.parent + 12;
72
+ const commandWidth = Math.max(20, available - fixedWidth);
73
+ const header = ["#".padEnd(widths.index), "STATE".padEnd(widths.state),
74
+ "PID".padEnd(widths.pid), "BG".padEnd(widths.background),
75
+ "ID".padEnd(widths.id), "PARENT".padEnd(widths.parent), "COMMAND"];
76
+ console.log(header.join(" "));
77
+ console.log("โ”€".repeat(Math.min(available, fixedWidth + commandWidth)));
78
+ for (const row of rows) {
79
+ console.log([
80
+ String(row.index).padEnd(widths.index),
81
+ String(row.state).padEnd(widths.state),
82
+ String(row.pid).padEnd(widths.pid),
83
+ String(row.background).padEnd(widths.background),
84
+ truncateMiddle(String(row.id), widths.id).padEnd(widths.id),
85
+ truncateMiddle(String(row.parent), widths.parent).padEnd(widths.parent),
86
+ truncate(String(row.command).replace(/\s+/g, " "), commandWidth),
87
+ ].join(" "));
88
+ }
89
+ }
33
90
  function printList(options: any): void {
34
- const rows = filtered(options).map(({ id, status }) => ({
35
- id, state: status.state, pid: status.pid || "-", background: status.background ? "yes" : "no",
91
+ const rows = filtered(options).map(({ id, status }, index) => ({
92
+ index, id, state: status.state, pid: status.pid || "-", background: status.background ? "yes" : "no",
36
93
  parent: status.parentProcessId || "-", command: [status.command, ...status.args].join(" "),
37
94
  }));
38
95
  if (options.json) console.log(JSON.stringify(rows, null, 2));
39
- else if (rows.length) console.table(rows);
96
+ else if (rows.length) printProcessTable(rows);
40
97
  else console.log("No managed processes.");
41
98
  }
42
99
  function addFilters(command: Command): Command {
@@ -71,6 +128,19 @@ async function followFiles(files: string[], fromEnd = true): Promise<void> {
71
128
  });
72
129
  }
73
130
 
131
+ function parseMaxAge(value: string): number {
132
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
133
+ if (!match) throw new Error(`Invalid max-age format: "${value}". Use e.g. 30m, 6h, 7d, 3600s.`);
134
+ const n = parseFloat(match[1]);
135
+ switch ((match[2] || "h").toLowerCase()) {
136
+ case "s": return n * 1000;
137
+ case "m": return n * 60 * 1000;
138
+ case "h": return n * 60 * 60 * 1000;
139
+ case "d": return n * 24 * 60 * 60 * 1000;
140
+ default: throw new Error(`Unknown unit in max-age: "${match[2]}"`);
141
+ }
142
+ }
143
+
74
144
  export function addProcessesCommand(program: Command): void {
75
145
  const processes = addFilters(program.command("processes").description("inspect and control managed processes"));
76
146
  processes.action((rawOptions, command) => printList(commandOptions(rawOptions, command)));
@@ -80,11 +150,19 @@ export function addProcessesCommand(program: Command): void {
80
150
  processes.command("run").description("internal managed process shim").requiredOption("--config <path>")
81
151
  .action(async ({ config }) => runManagedProcessShim(path.resolve(config)));
82
152
 
83
- processes.command("logs <id>").alias("tail").option("-n, --lines <number>", "lines to show", "100")
153
+ // Avoid Commander's built-in help exit here. The optional tracing module
154
+ // flushes asynchronously on process.exit, while Commander expects it never
155
+ // to return, which can otherwise produce a spurious "unknown option" error.
156
+ addFilters(processes.command("logs [id]").alias("tail"))
157
+ .helpOption(false)
158
+ .option("-h, --help", "display help for command")
159
+ .option("-i, --index <number>", "select by the zero-based index shown by processes list")
160
+ .option("-n, --lines <number>", "lines to show", "100")
84
161
  .option("-f, --follow", "follow output").option("--stderr", "show stderr instead of stdout")
85
162
  .option("--both", "show stdout and stderr").action(async (id, rawOptions, command) => {
86
163
  const options = commandOptions(rawOptions, command);
87
- const managed = resolveProcess(id);
164
+ if (options.help) { command.outputHelp(); return; }
165
+ const managed = resolveProcess(id, options.index, options);
88
166
  const files = options.both ? [managed.stdoutPath, managed.stderrPath] : [options.stderr ? managed.stderrPath : managed.stdoutPath];
89
167
  for (const file of files) process.stdout.write(tailText(file, Math.max(0, Number(options.lines) || 100)));
90
168
  if (options.follow) await followFiles(files);
@@ -135,4 +213,36 @@ export function addProcessesCommand(program: Command): void {
135
213
  });
136
214
  render(null, "");
137
215
  });
216
+
217
+ processes.command("cleanup")
218
+ .description("remove process directories older than max-age (default 24h)")
219
+ .option("--max-age <duration>", "max age before removal, e.g. 30m, 6h, 7d (default: 24h)", "24h")
220
+ .option("--dry-run", "show what would be removed without deleting")
221
+ .action(async (rawOptions) => {
222
+ const options = typeof rawOptions?.optsWithGlobals === "function" ? rawOptions.optsWithGlobals() : rawOptions;
223
+ const maxAgeMs = parseMaxAge(options.maxAge || "24h");
224
+ const processesDir = getProcessesDir();
225
+ if (!fs.existsSync(processesDir)) { console.log("No processes directory found."); return; }
226
+ const entries = fs.readdirSync(processesDir, { withFileTypes: true });
227
+ const now = Date.now();
228
+ let removed = 0;
229
+ for (const entry of entries) {
230
+ if (!entry.isDirectory()) continue;
231
+ const dirPath = path.join(processesDir, entry.name);
232
+ const statusPath = path.join(dirPath, "status.json");
233
+ if (!fs.existsSync(statusPath)) continue;
234
+ const stats = fs.statSync(dirPath);
235
+ const age = now - stats.mtimeMs;
236
+ if (age > maxAgeMs) {
237
+ if (options.dryRun) {
238
+ console.log(`[dry-run] would remove: ${entry.name} (age: ${Math.round(age / 60000)}m)`);
239
+ } else {
240
+ console.log(`๐Ÿงน Removing: ${entry.name} (age: ${Math.round(age / 60000)}m)`);
241
+ fs.rmSync(dirPath, { recursive: true, force: true });
242
+ }
243
+ removed++;
244
+ }
245
+ }
246
+ console.log(`${options.dryRun ? "[dry-run] " : ""}${removed} process director${removed === 1 ? "y" : "ies"} ${options.dryRun ? "would be" : ""} removed.`);
247
+ });
138
248
  }
package/src/hashes.ts CHANGED
@@ -19,11 +19,13 @@ export async function getHashes() {
19
19
  /**
20
20
  * Atomically save hashes to disk โ€” writes to a temp file then renames,
21
21
  * preventing concurrent writes from producing corrupted/truncated JSON.
22
+ * The hash directory is optional state and may not exist in a fresh checkout.
22
23
  */
23
24
  export async function saveHashes(hashes: any) {
24
25
  const target = ".knowhow/.hashes.json";
25
26
  const tmp = `${target}.tmp.${process.pid}`;
26
27
  try {
28
+ fs.mkdirSync(".knowhow", { recursive: true });
27
29
  fs.writeFileSync(tmp, JSON.stringify(hashes, null, 2));
28
30
  fs.renameSync(tmp, target);
29
31
  } catch (err) {
@@ -34,8 +34,9 @@ export function listManagedProcesses(processesDir = getProcessesDir()): ManagedP
34
34
  .sort((a, b) => b.status.startedAt.localeCompare(a.status.startedAt));
35
35
  }
36
36
  function makeId(command: string): string {
37
+ const ts = Date.now();
37
38
  const name = path.basename(command).replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 28) || "process";
38
- return `${name}-${Date.now()}-${randomBytes(3).toString("hex")}`;
39
+ return `${ts}-${name}-${randomBytes(3).toString("hex")}`;
39
40
  }
40
41
  function waitForFile(file: string, timeoutMs: number): Promise<void> {
41
42
  const started = Date.now();
@@ -70,12 +70,11 @@ export async function runManagedProcessShim(configPath: string): Promise<void> {
70
70
  fs.writeFileSync(path.join(dir, "pid"), child.pid ? `${child.pid}\n` : "");
71
71
  writeStatus();
72
72
 
73
- let fifoFd: number | undefined;
74
73
  let fifoStream: fs.ReadStream | undefined;
75
74
  if (process.platform !== "win32") {
76
75
  // O_RDWR prevents transient writers disconnecting from delivering EOF to the shim.
77
- fifoFd = fs.openSync(fifoPath, fs.constants.O_RDWR);
78
- fifoStream = fs.createReadStream("", { fd: fifoFd, autoClose: false });
76
+ const fifoFd = fs.openSync(fifoPath, fs.constants.O_RDWR);
77
+ fifoStream = fs.createReadStream("", { fd: fifoFd, autoClose: true });
79
78
  fifoStream.on("data", (chunk) => {
80
79
  fs.appendFileSync(inputLogPath, chunk);
81
80
  if (child.stdin?.writable) child.stdin.write(chunk);
@@ -110,6 +109,19 @@ export async function runManagedProcessShim(configPath: string): Promise<void> {
110
109
  });
111
110
  });
112
111
  if (parentTimer) clearInterval(parentTimer);
113
- fifoStream?.destroy();
114
- if (fifoFd !== undefined) try { fs.closeSync(fifoFd); } catch {}
112
+ if (fifoStream && !fifoStream.closed) {
113
+ // A FIFO read cannot always be cancelled by closing its descriptor (notably
114
+ // on macOS). Wake the pending read before asking ReadStream to close it;
115
+ // otherwise each completed command can leave its shim wedged in close(2).
116
+ const wakeFd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
117
+ try {
118
+ fs.writeSync(wakeFd, Buffer.from([0]));
119
+ } finally {
120
+ fs.closeSync(wakeFd);
121
+ }
122
+ await new Promise<void>((resolve) => {
123
+ fifoStream!.once("close", resolve);
124
+ fifoStream!.destroy();
125
+ });
126
+ }
115
127
  }