@tyvm/knowhow 0.0.146 → 0.0.147

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 (214) hide show
  1. package/autodoc/cli-reference.md +36 -7
  2. package/bin/knowhow.js +8 -6
  3. package/package.json +1 -1
  4. package/src/agents/base/base.ts +24 -0
  5. package/src/agents/historyRollback.ts +36 -0
  6. package/src/agents/tools/startAgentTask.ts +41 -9
  7. package/src/auth/browserLogin.ts +9 -3
  8. package/src/auth/keyAuth.ts +115 -0
  9. package/src/auth/keyManager.ts +166 -0
  10. package/src/chat/modules/AgentModule.ts +114 -33
  11. package/src/chat/modules/AskModule.ts +2 -0
  12. package/src/chat/modules/BaseChatModule.ts +0 -1
  13. package/src/chat/modules/CustomCommandsModule.ts +2 -0
  14. package/src/chat/modules/InternalChatModule.ts +2 -0
  15. package/src/chat/modules/RemoteSyncModule.ts +4 -0
  16. package/src/chat/modules/RendererModule.ts +2 -0
  17. package/src/chat/modules/SearchModule.ts +2 -0
  18. package/src/chat/modules/SessionsModule.ts +103 -34
  19. package/src/chat/modules/SetupModule.ts +2 -0
  20. package/src/chat/modules/ShellCommandModule.ts +2 -0
  21. package/src/chat/modules/SystemModule.ts +2 -0
  22. package/src/chat/types.ts +2 -0
  23. package/src/cli.ts +1 -1
  24. package/src/clients/index.ts +2 -0
  25. package/src/commands/agent.ts +1 -38
  26. package/src/commands/agents.ts +403 -53
  27. package/src/commands/misc.ts +3 -2
  28. package/src/login.ts +89 -28
  29. package/src/services/AgentService.ts +2 -0
  30. package/src/services/AgentSyncFs.ts +65 -0
  31. package/src/services/AgentSyncKnowhowWeb.ts +22 -4
  32. package/src/services/BehaviorsService.ts +2 -0
  33. package/src/services/DockerService.ts +2 -0
  34. package/src/services/EmbeddingService.ts +2 -0
  35. package/src/services/EmbeddingsService.ts +3 -0
  36. package/src/services/KnowhowClient.ts +68 -7
  37. package/src/services/LazyToolsService.ts +9 -0
  38. package/src/services/Mcp.ts +2 -0
  39. package/src/services/McpServer.ts +2 -0
  40. package/src/services/MessageProcessor.ts +2 -0
  41. package/src/services/MinimalToolsService.ts +2 -0
  42. package/src/services/SessionManager.ts +5 -0
  43. package/src/services/SkillsService.ts +2 -0
  44. package/src/services/SyncedAgentWatcher.ts +2 -0
  45. package/src/services/SyncerService.ts +23 -8
  46. package/src/services/TaskRegistry.ts +2 -0
  47. package/src/services/Tools.ts +2 -0
  48. package/src/services/index.ts +2 -0
  49. package/src/services/modules/index.ts +4 -1
  50. package/src/types.ts +2 -0
  51. package/src/util/Trace.ts +1 -1
  52. package/src/utils/http.ts +18 -2
  53. package/tests/unit/agents/historyRollback.test.ts +53 -0
  54. package/tests/unit/auth/keyAuth.test.ts +102 -0
  55. package/tests/unit/auth/keyManager.test.ts +67 -0
  56. package/tests/unit/chat/historyCommandArgs.test.ts +37 -0
  57. package/tests/unit/commands/agents.test.ts +68 -0
  58. package/tests/unit/login.test.ts +119 -0
  59. package/tests/unit/services/AgentSyncKnowhowWeb.test.ts +38 -0
  60. package/tests/unit/services/LazyToolsService.test.ts +29 -0
  61. package/tests/unit/services/SessionManager.test.ts +43 -0
  62. package/tests/unit/services/SyncerService.test.ts +45 -0
  63. package/tests/unit/util/Trace.test.ts +119 -0
  64. package/tests/unit/utils/http.test.ts +43 -0
  65. package/ts_build/package.json +1 -1
  66. package/ts_build/src/agents/base/base.d.ts +1 -0
  67. package/ts_build/src/agents/base/base.js +31 -2
  68. package/ts_build/src/agents/base/base.js.map +1 -1
  69. package/ts_build/src/agents/historyRollback.d.ts +2 -0
  70. package/ts_build/src/agents/historyRollback.js +22 -0
  71. package/ts_build/src/agents/historyRollback.js.map +1 -0
  72. package/ts_build/src/agents/tools/startAgentTask.d.ts +2 -0
  73. package/ts_build/src/agents/tools/startAgentTask.js +36 -9
  74. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  75. package/ts_build/src/auth/browserLogin.d.ts +1 -0
  76. package/ts_build/src/auth/browserLogin.js +5 -2
  77. package/ts_build/src/auth/browserLogin.js.map +1 -1
  78. package/ts_build/src/auth/keyAuth.d.ts +5 -0
  79. package/ts_build/src/auth/keyAuth.js +79 -0
  80. package/ts_build/src/auth/keyAuth.js.map +1 -0
  81. package/ts_build/src/auth/keyManager.d.ts +14 -0
  82. package/ts_build/src/auth/keyManager.js +145 -0
  83. package/ts_build/src/auth/keyManager.js.map +1 -0
  84. package/ts_build/src/chat/modules/AgentModule.d.ts +13 -1
  85. package/ts_build/src/chat/modules/AgentModule.js +83 -24
  86. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  87. package/ts_build/src/chat/modules/AskModule.js +12 -2
  88. package/ts_build/src/chat/modules/AskModule.js.map +1 -1
  89. package/ts_build/src/chat/modules/BaseChatModule.js.map +1 -1
  90. package/ts_build/src/chat/modules/CustomCommandsModule.js +12 -2
  91. package/ts_build/src/chat/modules/CustomCommandsModule.js.map +1 -1
  92. package/ts_build/src/chat/modules/InternalChatModule.js +16 -2
  93. package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
  94. package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -2
  95. package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
  96. package/ts_build/src/chat/modules/RendererModule.js +17 -2
  97. package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
  98. package/ts_build/src/chat/modules/SearchModule.js +12 -2
  99. package/ts_build/src/chat/modules/SearchModule.js.map +1 -1
  100. package/ts_build/src/chat/modules/SessionsModule.d.ts +6 -0
  101. package/ts_build/src/chat/modules/SessionsModule.js +113 -18
  102. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  103. package/ts_build/src/chat/modules/SetupModule.js +17 -2
  104. package/ts_build/src/chat/modules/SetupModule.js.map +1 -1
  105. package/ts_build/src/chat/modules/ShellCommandModule.js +12 -2
  106. package/ts_build/src/chat/modules/ShellCommandModule.js.map +1 -1
  107. package/ts_build/src/chat/modules/SystemModule.js +12 -2
  108. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  109. package/ts_build/src/chat/types.d.ts +2 -0
  110. package/ts_build/src/cli.js +1 -1
  111. package/ts_build/src/cli.js.map +1 -1
  112. package/ts_build/src/clients/index.js +19 -4
  113. package/ts_build/src/clients/index.js.map +1 -1
  114. package/ts_build/src/commands/agent.js +1 -21
  115. package/ts_build/src/commands/agent.js.map +1 -1
  116. package/ts_build/src/commands/agents.d.ts +1 -1
  117. package/ts_build/src/commands/agents.js +174 -16
  118. package/ts_build/src/commands/agents.js.map +1 -1
  119. package/ts_build/src/commands/misc.js +3 -2
  120. package/ts_build/src/commands/misc.js.map +1 -1
  121. package/ts_build/src/login.d.ts +1 -1
  122. package/ts_build/src/login.js +53 -15
  123. package/ts_build/src/login.js.map +1 -1
  124. package/ts_build/src/services/AgentService.js +18 -2
  125. package/ts_build/src/services/AgentService.js.map +1 -1
  126. package/ts_build/src/services/AgentSyncFs.d.ts +3 -0
  127. package/ts_build/src/services/AgentSyncFs.js +62 -15
  128. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  129. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +2 -0
  130. package/ts_build/src/services/AgentSyncKnowhowWeb.js +28 -6
  131. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  132. package/ts_build/src/services/BehaviorsService.js +12 -2
  133. package/ts_build/src/services/BehaviorsService.js.map +1 -1
  134. package/ts_build/src/services/DockerService.js +20 -8
  135. package/ts_build/src/services/DockerService.js.map +1 -1
  136. package/ts_build/src/services/EmbeddingService.js +16 -2
  137. package/ts_build/src/services/EmbeddingService.js.map +1 -1
  138. package/ts_build/src/services/EmbeddingsService.js +12 -2
  139. package/ts_build/src/services/EmbeddingsService.js.map +1 -1
  140. package/ts_build/src/services/KnowhowClient.d.ts +7 -2
  141. package/ts_build/src/services/KnowhowClient.js +108 -10
  142. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  143. package/ts_build/src/services/LazyToolsService.d.ts +1 -0
  144. package/ts_build/src/services/LazyToolsService.js +21 -2
  145. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  146. package/ts_build/src/services/Mcp.js +12 -2
  147. package/ts_build/src/services/Mcp.js.map +1 -1
  148. package/ts_build/src/services/McpServer.js +17 -2
  149. package/ts_build/src/services/McpServer.js.map +1 -1
  150. package/ts_build/src/services/MessageProcessor.js +16 -2
  151. package/ts_build/src/services/MessageProcessor.js.map +1 -1
  152. package/ts_build/src/services/MinimalToolsService.js +16 -2
  153. package/ts_build/src/services/MinimalToolsService.js.map +1 -1
  154. package/ts_build/src/services/SessionManager.js +19 -2
  155. package/ts_build/src/services/SessionManager.js.map +1 -1
  156. package/ts_build/src/services/SkillsService.js +16 -2
  157. package/ts_build/src/services/SkillsService.js.map +1 -1
  158. package/ts_build/src/services/SyncedAgentWatcher.js +16 -2
  159. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  160. package/ts_build/src/services/SyncerService.d.ts +1 -0
  161. package/ts_build/src/services/SyncerService.js +27 -5
  162. package/ts_build/src/services/SyncerService.js.map +1 -1
  163. package/ts_build/src/services/TaskRegistry.js +12 -2
  164. package/ts_build/src/services/TaskRegistry.js.map +1 -1
  165. package/ts_build/src/services/Tools.js +16 -2
  166. package/ts_build/src/services/Tools.js.map +1 -1
  167. package/ts_build/src/services/index.d.ts +2 -0
  168. package/ts_build/src/services/index.js +4 -2
  169. package/ts_build/src/services/index.js.map +1 -1
  170. package/ts_build/src/services/modules/index.d.ts +2 -1
  171. package/ts_build/src/services/modules/index.js +1 -1
  172. package/ts_build/src/services/modules/index.js.map +1 -1
  173. package/ts_build/src/types.d.ts +1 -0
  174. package/ts_build/src/types.js.map +1 -1
  175. package/ts_build/src/util/Trace.d.ts +1 -3
  176. package/ts_build/src/utils/http.d.ts +6 -2
  177. package/ts_build/src/utils/http.js +8 -2
  178. package/ts_build/src/utils/http.js.map +1 -1
  179. package/ts_build/tests/unit/agents/historyRollback.test.d.ts +1 -0
  180. package/ts_build/tests/unit/agents/historyRollback.test.js +42 -0
  181. package/ts_build/tests/unit/agents/historyRollback.test.js.map +1 -0
  182. package/ts_build/tests/unit/auth/keyAuth.test.d.ts +1 -0
  183. package/ts_build/tests/unit/auth/keyAuth.test.js +77 -0
  184. package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -0
  185. package/ts_build/tests/unit/auth/keyManager.test.d.ts +1 -0
  186. package/ts_build/tests/unit/auth/keyManager.test.js +52 -0
  187. package/ts_build/tests/unit/auth/keyManager.test.js.map +1 -0
  188. package/ts_build/tests/unit/chat/historyCommandArgs.test.d.ts +1 -0
  189. package/ts_build/tests/unit/chat/historyCommandArgs.test.js +29 -0
  190. package/ts_build/tests/unit/chat/historyCommandArgs.test.js.map +1 -0
  191. package/ts_build/tests/unit/commands/agents.test.d.ts +1 -0
  192. package/ts_build/tests/unit/commands/agents.test.js +91 -0
  193. package/ts_build/tests/unit/commands/agents.test.js.map +1 -0
  194. package/ts_build/tests/unit/login.test.d.ts +1 -0
  195. package/ts_build/tests/unit/login.test.js +97 -0
  196. package/ts_build/tests/unit/login.test.js.map +1 -0
  197. package/ts_build/tests/unit/services/AgentSyncKnowhowWeb.test.d.ts +1 -0
  198. package/ts_build/tests/unit/services/AgentSyncKnowhowWeb.test.js +32 -0
  199. package/ts_build/tests/unit/services/AgentSyncKnowhowWeb.test.js.map +1 -0
  200. package/ts_build/tests/unit/services/LazyToolsService.test.d.ts +1 -0
  201. package/ts_build/tests/unit/services/LazyToolsService.test.js +27 -0
  202. package/ts_build/tests/unit/services/LazyToolsService.test.js.map +1 -0
  203. package/ts_build/tests/unit/services/SessionManager.test.d.ts +1 -0
  204. package/ts_build/tests/unit/services/SessionManager.test.js +72 -0
  205. package/ts_build/tests/unit/services/SessionManager.test.js.map +1 -0
  206. package/ts_build/tests/unit/services/SyncerService.test.d.ts +1 -0
  207. package/ts_build/tests/unit/services/SyncerService.test.js +40 -0
  208. package/ts_build/tests/unit/services/SyncerService.test.js.map +1 -0
  209. package/ts_build/tests/unit/util/Trace.test.d.ts +1 -0
  210. package/ts_build/tests/unit/util/Trace.test.js +107 -0
  211. package/ts_build/tests/unit/util/Trace.test.js.map +1 -0
  212. package/ts_build/tests/unit/utils/http.test.d.ts +1 -0
  213. package/ts_build/tests/unit/utils/http.test.js +69 -0
  214. package/ts_build/tests/unit/utils/http.test.js.map +1 -0
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { Message } from "../clients/types";
7
7
  import { EventService } from "./EventService";
8
+ import { TraceAll } from "../util/Trace";
8
9
 
9
10
  export interface SyncedAgentWatcher {
10
11
  /** Start watching for changes, emitting agent events */
@@ -73,6 +74,7 @@ export interface AttachableAgent {
73
74
  * - agentEvents and eventTypes are delegated to the watcher
74
75
  * - "done" is emitted by the watcher when the remote agent completes
75
76
  */
77
+ @TraceAll()
76
78
  export class WatcherBackedAgent implements AttachableAgent {
77
79
  public name: string;
78
80
  public agentEvents: EventService;
@@ -3,6 +3,7 @@
3
3
  * Hides complexity of choosing and managing sync backends.
4
4
  */
5
5
  import { BaseAgent } from "../agents/base/base";
6
+ import { TraceAll } from "../util/Trace";
6
7
  import { AgentSyncFs } from "./AgentSyncFs";
7
8
  import { AgentSyncKnowhowWeb } from "./AgentSyncKnowhowWeb";
8
9
 
@@ -11,12 +12,13 @@ export interface SyncerOptions {
11
12
  prompt: string;
12
13
  /** If provided → use web sync (unless syncFs is set) */
13
14
  messageId?: string;
15
+ /** Existing remote Knowhow task UUID. Never inferred from the local taskId. */
16
+ remoteTaskId?: string;
14
17
  /** Force fs sync even if messageId is provided */
15
18
  syncFs?: boolean;
16
19
  /**
17
- * Push the agent's work to a remote Knowhow task. When set (and there is no
18
- * messageId), the `taskId` is treated as the remote task ID to attach to and
19
- * push updates to, rather than creating a new remote task.
20
+ * Push the agent's work to the existing `remoteTaskId`. The local taskId is
21
+ * always a filesystem identity and is never used as a remote identifier.
20
22
  */
21
23
  syncRemote?: boolean;
22
24
  /** Agent name to persist in metadata.json */
@@ -44,8 +46,9 @@ export interface AgentSyncer {
44
46
  * Decision logic:
45
47
  * - Always sets up AgentSyncFs (primary local syncer)
46
48
  * - If messageId is present AND syncFs is not forced → creates a new remote task via AgentSyncKnowhowWeb
47
- * - If syncRemote is set (and no messageId) → attaches to the remote task identified by taskId
49
+ * - If syncRemote and a valid remoteTaskId are set → attaches to that UUID
48
50
  */
51
+ @TraceAll()
49
52
  export class SyncerService implements AgentSyncer {
50
53
  private fsSync: AgentSyncFs;
51
54
  private webSync: AgentSyncKnowhowWeb;
@@ -75,14 +78,16 @@ export class SyncerService implements AgentSyncer {
75
78
 
76
79
  // Determine whether to ATTACH to an already-existing remote task and push
77
80
  // local work to it (no creation needed). This is the "--sync-remote" path:
78
- // the caller wants to push work to the remote task identified by taskId.
81
+ // the caller wants to push work to an explicitly identified remote task.
79
82
  // We only auto-attach when there is NO messageId (messageId always creates
80
83
  // a fresh remote task above).
81
84
  //
82
85
  // This is orthogonal to fs sync: we can push to the remote task AND keep
83
86
  // the local .knowhow/processes/agents/<id>/ files in sync at the same time.
84
87
  const shouldAttachExisting =
85
- !!options.syncRemote && !options.messageId && !!options.taskId;
88
+ !!options.syncRemote &&
89
+ !options.messageId &&
90
+ isUuid(options.remoteTaskId);
86
91
 
87
92
  // Always create fs sync task first
88
93
  console.log(
@@ -104,6 +109,7 @@ export class SyncerService implements AgentSyncer {
104
109
 
105
110
  if (knowhowTaskId) {
106
111
  this.useWebSync = true;
112
+ await this.fsSync.setRemoteIdentity(knowhowTaskId, options.messageId);
107
113
  this.createdTaskId = knowhowTaskId;
108
114
  console.log(`🌐 Web sync task created: ${knowhowTaskId}`);
109
115
  }
@@ -112,9 +118,10 @@ export class SyncerService implements AgentSyncer {
112
118
  // Attach live web sync to an existing remote task (push updates to it).
113
119
  if (!shouldUseWebSync && shouldAttachExisting) {
114
120
  this.useWebSync = true;
115
- this.createdTaskId = options.taskId;
121
+ await this.fsSync.setRemoteIdentity(options.remoteTaskId!);
122
+ this.createdTaskId = options.remoteTaskId;
116
123
  console.log(
117
- `🌐 Attaching web sync to existing remote task: ${options.taskId}`
124
+ `🌐 Attaching web sync to existing remote task: ${options.remoteTaskId}`
118
125
  );
119
126
  }
120
127
 
@@ -174,3 +181,11 @@ export class SyncerService implements AgentSyncer {
174
181
  return this.useWebSync ? this.createdTaskId : undefined;
175
182
  }
176
183
  }
184
+
185
+ /** Remote task IDs are UUIDs; local filesystem IDs are timestamped slugs. */
186
+ function isUuid(value: string | undefined): value is string {
187
+ return !!value &&
188
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
189
+ value
190
+ );
191
+ }
@@ -2,10 +2,12 @@
2
2
  * Task Registry Service - Manages active agent tasks
3
3
  */
4
4
  import { TaskInfo } from "../chat/types";
5
+ import { TraceAll } from "../util/Trace";
5
6
 
6
7
  /**
7
8
  * TaskRegistry manages the in-memory registry of active agent tasks
8
9
  */
10
+ @TraceAll()
9
11
  export class TaskRegistry {
10
12
  private tasks = new Map<string, TaskInfo>();
11
13
 
@@ -1,4 +1,5 @@
1
1
  import { ChatCompletionTool } from "openai/resources/chat";
2
+ import { TraceAll } from "../util/Trace";
2
3
  import { replaceEscapedNewLines, restoreEscapedNewLines } from "../utils";
3
4
  import { includedTools } from "../agents/tools/list";
4
5
  import { AgentService } from "./AgentService";
@@ -61,6 +62,7 @@ export interface ToolContext {
61
62
  * larger context bloat from having every tool defined in system prompt
62
63
  *
63
64
  */
65
+ @TraceAll()
64
66
  export class ToolsService {
65
67
  private context: ToolContext = {};
66
68
 
@@ -51,6 +51,7 @@ let Singletons = {} as {
51
51
  Tools: ToolsService;
52
52
  Events: EventService;
53
53
  Agents: AgentService;
54
+ Tracing: typeof TracingService;
54
55
  Embeddings: EmbeddingsService;
55
56
  Flags: FlagsService;
56
57
  Mcp: McpService;
@@ -83,6 +84,7 @@ export const services = (): typeof Singletons => {
83
84
  Singletons = {
84
85
  Agents,
85
86
  AwsS3: new S3Service(),
87
+ Tracing: TracingService,
86
88
  Clients,
87
89
  Docker: new DockerService(),
88
90
  Events,
@@ -284,9 +284,12 @@ export class ModulesService {
284
284
 
285
285
  // Load global-config modules resolving against the global install's
286
286
  // node_modules (~/.knowhow/node_modules) first.
287
+ // Pass a merged config so that keys defined in the global config (e.g.
288
+ // `tracing`) are visible to globally-declared modules during init().
289
+ // Local config keys take precedence so that local overrides still work.
287
290
  if (globalModules.length > 0) {
288
291
  await this.loadModulesFrom(
289
- { ...config, modules: globalModules },
292
+ { ...globalConfig, ...config, modules: globalModules },
290
293
  context,
291
294
  globalResolvePaths()
292
295
  );
package/src/types.ts CHANGED
@@ -75,6 +75,8 @@ export type Config = {
75
75
  promptsDir: string;
76
76
  lintCommands?: { [fileExtension: string]: string };
77
77
  orgId?: string;
78
+ /** Private Ed25519 identity selected with `knowhow login --identity`. */
79
+ cliIdentityPath?: string;
78
80
  syncRemote?: boolean;
79
81
  micCommand?: string;
80
82
  defaultMic?: string;
package/src/util/Trace.ts CHANGED
@@ -141,7 +141,7 @@ export interface TraceAllOptions {
141
141
  export function TraceAll(options: TraceAllOptions = {}) {
142
142
  const skip = new Set(options.skip ?? []);
143
143
 
144
- return function <T extends { new (...args: unknown[]): object }>(
144
+ return function <T extends abstract new (...args: unknown[]) => object>(
145
145
  constructor: T
146
146
  ): T {
147
147
  const className = constructor.name;
package/src/utils/http.ts CHANGED
@@ -21,6 +21,15 @@ export class HttpError extends Error {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * Optional, non-enumerable hook carried by an authenticated headers object.
26
+ * The hook may replace an expired credential before one safe 401 retry.
27
+ */
28
+ export const HTTP_UNAUTHORIZED_HANDLER = Symbol("httpUnauthorizedHandler");
29
+ export type RefreshableHeaders = Record<string, string> & {
30
+ [HTTP_UNAUTHORIZED_HANDLER]?: () => Promise<void>;
31
+ };
32
+
24
33
  async function parseBody<T>(response: Response, responseType?: string): Promise<T> {
25
34
  if (responseType === "arraybuffer") {
26
35
  return (await response.arrayBuffer()) as unknown as T;
@@ -41,13 +50,14 @@ async function request<T = any>(
41
50
  url: string,
42
51
  options: {
43
52
  method?: string;
44
- headers?: Record<string, string>;
53
+ headers?: RefreshableHeaders;
45
54
  body?: any;
46
55
  responseType?: "json" | "arraybuffer" | "stream" | "text";
47
56
  params?: Record<string, any>;
48
57
  /** Timeout in milliseconds. Default: 30000 (30s). Use 0 to disable. */
49
58
  timeout?: number;
50
- } = {}
59
+ } = {},
60
+ mayRefresh = true
51
61
  ): Promise<HttpResponse<T>> {
52
62
  const { method = "GET", headers = {}, body, responseType, params, timeout = 30000 } = options;
53
63
 
@@ -102,6 +112,12 @@ async function request<T = any>(
102
112
  }
103
113
 
104
114
  if (!response.ok) {
115
+ const refresh = headers[HTTP_UNAUTHORIZED_HANDLER];
116
+ if (response.status === 401 && mayRefresh && refresh) {
117
+ await refresh();
118
+ return request<T>(url, options, false);
119
+ }
120
+
105
121
  const text = await response.text().catch(() => "");
106
122
  let parsedBody: any;
107
123
  try {
@@ -0,0 +1,53 @@
1
+ import { rollbackAgentInteractions } from "../../../src/agents/historyRollback";
2
+ import { Message } from "../../../src/clients/types";
3
+
4
+ const message = (role: Message["role"], content: string): Message => ({ role, content });
5
+
6
+ describe("rollbackAgentInteractions", () => {
7
+ const threads: Message[][] = [[
8
+ message("user", "request"),
9
+ message("assistant", "first"),
10
+ message("tool", "first result"),
11
+ message("assistant", "second"),
12
+ message("tool", "second result"),
13
+ ]];
14
+
15
+ it("clones history without changing it when rollback is zero", () => {
16
+ const result = rollbackAgentInteractions(threads, 0);
17
+ expect(result).toEqual(threads);
18
+ expect(result).not.toBe(threads);
19
+ expect(result[0]).not.toBe(threads[0]);
20
+ expect(result[0][0]).not.toBe(threads[0][0]);
21
+ });
22
+
23
+ it("removes the newest assistant and its following tool results", () => {
24
+ expect(rollbackAgentInteractions(threads, 1)).toEqual([threads[0].slice(0, 3)]);
25
+ });
26
+
27
+ it("can remove multiple interactions", () => {
28
+ expect(rollbackAgentInteractions(threads, 2)).toEqual([[threads[0][0]]]);
29
+ });
30
+
31
+ it("removes a trailing assistant without tools", () => {
32
+ const input = [[message("user", "request"), message("assistant", "tail")]];
33
+ expect(rollbackAgentInteractions(input, 1)).toEqual([[input[0][0]]]);
34
+ });
35
+
36
+ it.each([-1, 1.5])("rejects invalid rollback %p", (rollback) => {
37
+ expect(() => rollbackAgentInteractions(threads, rollback)).toThrow(
38
+ "rollback must be a non-negative integer"
39
+ );
40
+ });
41
+
42
+ it("rejects rollback beyond available interactions", () => {
43
+ expect(() => rollbackAgentInteractions(threads, 3)).toThrow(
44
+ "only has 2 assistant interaction(s)"
45
+ );
46
+ });
47
+
48
+ it("does not mutate input history", () => {
49
+ const before = JSON.stringify(threads);
50
+ rollbackAgentInteractions(threads, 1);
51
+ expect(JSON.stringify(threads)).toBe(before);
52
+ });
53
+ });
@@ -0,0 +1,102 @@
1
+ const post = jest.fn();
2
+ const publicKeyBase64 = Buffer.alloc(32, 7).toString("base64");
3
+ const keyPairExists = jest.fn(() => true);
4
+ const loadKeyPair = jest.fn(() => ({ publicKeyBase64 }));
5
+ const signMessage = jest.fn(() => "server-message-signature");
6
+
7
+ jest.mock("../../../src/utils/http", () => ({
8
+ __esModule: true,
9
+ default: {
10
+ post,
11
+ isHttpError: (error: unknown) =>
12
+ typeof error === "object" && error !== null && "status" in error,
13
+ },
14
+ }));
15
+
16
+ jest.mock("../../../src/auth/keyManager", () => ({
17
+ keyPairExists,
18
+ loadKeyPair,
19
+ signMessage,
20
+ }));
21
+
22
+ jest.mock("../../../src/auth/browserLogin", () => ({
23
+ getCliUserAgent: () => "test-agent",
24
+ }));
25
+
26
+ import {
27
+ authenticateWithKey,
28
+ exchangePublicKeyJwt,
29
+ hasKeyPair,
30
+ registerPublicKey,
31
+ } from "../../../src/auth/keyAuth";
32
+
33
+ describe("keyAuth", () => {
34
+ beforeEach(() => {
35
+ jest.clearAllMocks();
36
+ keyPairExists.mockReturnValue(true);
37
+ });
38
+
39
+ it("signs the exact server challenge for the selected key, org, and environment", async () => {
40
+ post
41
+ .mockResolvedValueOnce({
42
+ data: {
43
+ challengeId: "challenge_1",
44
+ message: "exact message from server",
45
+ expiresAt: "2099-01-01T00:00:00.000Z",
46
+ },
47
+ status: 201,
48
+ })
49
+ .mockResolvedValueOnce({ data: { jwt: "renewed-jwt" }, status: 200 });
50
+
51
+ expect(hasKeyPair("/keys/work")).toBe(true);
52
+ await expect(
53
+ exchangePublicKeyJwt("org-1", "https://work.example", "/keys/work")
54
+ ).resolves.toBe("renewed-jwt");
55
+
56
+ expect(loadKeyPair).toHaveBeenCalledWith("/keys/work");
57
+ expect(signMessage).toHaveBeenCalledWith("exact message from server", "/keys/work");
58
+ expect(post.mock.calls).toEqual([
59
+ [
60
+ "https://work.example/api/public-keys/challenge",
61
+ { publicKey: publicKeyBase64, orgId: "org-1" },
62
+ { headers: { "User-Agent": "test-agent" } },
63
+ ],
64
+ [
65
+ "https://work.example/api/public-keys/authenticate",
66
+ { challengeId: "challenge_1", signature: "server-message-signature" },
67
+ { headers: { "User-Agent": "test-agent" } },
68
+ ],
69
+ ]);
70
+ });
71
+
72
+ it("falls back when the key is unavailable without signing a challenge", async () => {
73
+ post.mockRejectedValueOnce({ status: 401 });
74
+
75
+ await expect(
76
+ authenticateWithKey("org-1", "https://work.example", "/keys/work")
77
+ ).resolves.toBe(false);
78
+
79
+ expect(post).toHaveBeenCalledTimes(1);
80
+ expect(signMessage).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it("registers only server-owned Ed25519 key fields in the selected environment", async () => {
84
+ post.mockResolvedValueOnce({ data: {}, status: 201 });
85
+
86
+ await registerPublicKey("browser-jwt", publicKeyBase64, "https://dev.example");
87
+
88
+ expect(post).toHaveBeenCalledWith(
89
+ "https://dev.example/api/public-keys/register",
90
+ {
91
+ publicKey: publicKeyBase64,
92
+ label: expect.any(String),
93
+ },
94
+ {
95
+ headers: {
96
+ Authorization: "Bearer browser-jwt",
97
+ "User-Agent": "test-agent",
98
+ },
99
+ }
100
+ );
101
+ });
102
+ });
@@ -0,0 +1,67 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import {
6
+ generateKeyPair,
7
+ loadKeyPair,
8
+ signMessage,
9
+ } from "../../../src/auth/keyManager";
10
+
11
+ describe("keyManager", () => {
12
+ let directory: string;
13
+ let identity: string;
14
+
15
+ beforeEach(() => {
16
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-key-manager-"));
17
+ identity = path.join(directory, "identity");
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(directory, { recursive: true, force: true });
22
+ });
23
+
24
+ it("creates an Ed25519 identity with an OpenSSH-compatible public key", () => {
25
+ const generated = generateKeyPair(identity);
26
+ const publicKeyLine = fs.readFileSync(`${identity}.pub`, "utf8").trim();
27
+
28
+ expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+=* knowhow-cli$/);
29
+ expect(fs.statSync(identity).mode % 0o1000).toBe(0o600);
30
+ expect(loadKeyPair(identity)).toEqual(generated);
31
+
32
+ const challengeMessage = "exact server-provided challenge";
33
+ const signature = signMessage(challengeMessage, identity);
34
+ const publicKey = crypto.createPublicKey(fs.readFileSync(identity));
35
+ expect(
36
+ crypto.verify(
37
+ null,
38
+ Buffer.from(challengeMessage),
39
+ publicKey,
40
+ Buffer.from(signature, "base64")
41
+ )
42
+ ).toBe(true);
43
+ });
44
+
45
+ it("loads legacy raw-base64 public keys but rejects non-Ed25519 identities", () => {
46
+ const generated = generateKeyPair(identity);
47
+ fs.writeFileSync(`${identity}.pub`, generated.publicKeyBase64);
48
+ expect(loadKeyPair(identity).publicKeyBase64).toBe(generated.publicKeyBase64);
49
+
50
+ const rsaIdentity = path.join(directory, "rsa");
51
+ const rsa = crypto.generateKeyPairSync("rsa", {
52
+ modulusLength: 2048,
53
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
54
+ publicKeyEncoding: { type: "spki", format: "pem" },
55
+ });
56
+ fs.writeFileSync(rsaIdentity, rsa.privateKey);
57
+ fs.writeFileSync(`${rsaIdentity}.pub`, generated.publicKeyBase64);
58
+
59
+ expect(() => loadKeyPair(rsaIdentity)).toThrow("must be an Ed25519 private key");
60
+ });
61
+
62
+ it("does not overwrite a partial identity", () => {
63
+ fs.writeFileSync(identity, "existing key");
64
+ expect(() => generateKeyPair(identity)).toThrow("a key file already exists");
65
+ expect(fs.readFileSync(identity, "utf8")).toBe("existing key");
66
+ });
67
+ });
@@ -0,0 +1,37 @@
1
+ import { parseHistoryCommandArgs } from "../../../src/chat/modules/SessionsModule";
2
+
3
+ describe("parseHistoryCommandArgs", () => {
4
+ it("parses an id and rollback in either order", () => {
5
+ expect(parseHistoryCommandArgs(["task-1", "--rollback", "2"])).toEqual({
6
+ id: "task-1", rollback: 2, showAll: false,
7
+ });
8
+ expect(parseHistoryCommandArgs(["--rollback", "1", "task-1"])).toEqual({
9
+ id: "task-1", rollback: 1, showAll: false,
10
+ });
11
+ });
12
+
13
+ it("supports interactive --all", () => {
14
+ expect(parseHistoryCommandArgs(["--all"])).toEqual({
15
+ id: undefined, rollback: 0, showAll: true,
16
+ });
17
+ });
18
+
19
+ it.each([
20
+ [["--rollback"]],
21
+ [["--rollback", "-1"]],
22
+ [["--rollback", "1.5"]],
23
+ ])(
24
+ "rejects invalid rollback arguments: %p",
25
+ (args) => {
26
+ expect(() => parseHistoryCommandArgs(args)).toThrow(
27
+ "--rollback must be a non-negative integer"
28
+ );
29
+ }
30
+ );
31
+
32
+ it("rejects unexpected arguments", () => {
33
+ expect(() => parseHistoryCommandArgs(["one", "two"])).toThrow(
34
+ "Unexpected argument: two"
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,68 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { Command } from "commander";
5
+
6
+ const startChat = jest.fn().mockResolvedValue(undefined);
7
+ const setupServices = jest.fn().mockResolvedValue(undefined);
8
+
9
+ jest.mock("../../../src/chat", () => ({ startChat }));
10
+ jest.mock("../../../src/commands/services", () => ({ setupServices }));
11
+
12
+ import { addAgentsCommand } from "../../../src/commands/agents";
13
+
14
+ describe("agents resume terminal UX", () => {
15
+ let previousCwd: string;
16
+ let tempDir: string;
17
+ let isTTYDescriptor: PropertyDescriptor | undefined;
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ previousCwd = process.cwd();
22
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-agents-command-"));
23
+ process.chdir(tempDir);
24
+ const taskId = "1700000000-resumable-task";
25
+ fs.mkdirSync(path.join(".knowhow", "processes", "agents", taskId), {
26
+ recursive: true,
27
+ });
28
+ fs.writeFileSync(
29
+ path.join(".knowhow", "processes", "agents", taskId, "metadata.json"),
30
+ JSON.stringify({ taskId, status: "completed", threads: [[]] })
31
+ );
32
+ isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
33
+ Object.defineProperty(process.stdin, "isTTY", {
34
+ configurable: true,
35
+ value: true,
36
+ });
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.chdir(previousCwd);
41
+ fs.rmSync(tempDir, { recursive: true, force: true });
42
+ if (isTTYDescriptor) {
43
+ Object.defineProperty(process.stdin, "isTTY", isTTYDescriptor);
44
+ } else {
45
+ delete (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY;
46
+ }
47
+ });
48
+
49
+ test("opens chat with the interactive resume command by default", async () => {
50
+ const program = new Command();
51
+ addAgentsCommand(program, jest.fn());
52
+
53
+ await program.parseAsync([
54
+ "node",
55
+ "knowhow",
56
+ "agents",
57
+ "resume",
58
+ "1700000000-resumable-task",
59
+ "--rollback",
60
+ "2",
61
+ ]);
62
+
63
+ expect(setupServices).toHaveBeenCalledTimes(1);
64
+ expect(startChat).toHaveBeenCalledWith(
65
+ "/resume 1700000000-resumable-task --rollback 2"
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,119 @@
1
+ const httpGet = jest.fn();
2
+ const browserLogin = jest.fn();
3
+ const hasKeyPair = jest.fn();
4
+ const authenticateWithKey = jest.fn();
5
+ const registerPublicKey = jest.fn();
6
+ const getOrCreatePublicKey = jest.fn();
7
+ const getConfig = jest.fn();
8
+ const updateConfig = jest.fn();
9
+
10
+ jest.mock("../../src/utils/http", () => ({
11
+ __esModule: true,
12
+ default: { get: httpGet, isHttpError: () => false },
13
+ }));
14
+
15
+ jest.mock("../../src/services/KnowhowClient", () => ({
16
+ KNOWHOW_API_URL: "https://api.example",
17
+ }));
18
+
19
+ jest.mock("../../src/auth/browserLogin", () => ({
20
+ BrowserLoginService: jest.fn().mockImplementation(() => ({ login: browserLogin })),
21
+ }));
22
+
23
+ jest.mock("../../src/auth/keyAuth", () => ({
24
+ hasKeyPair,
25
+ authenticateWithKey,
26
+ registerPublicKey,
27
+ }));
28
+
29
+ jest.mock("../../src/auth/keyManager", () => ({ getOrCreatePublicKey }));
30
+ jest.mock("../../src/config", () => ({ getConfig, updateConfig }));
31
+ jest.mock("../../src/utils", () => ({ ask: jest.fn() }));
32
+
33
+ import fs from "fs";
34
+ import os from "os";
35
+ import path from "path";
36
+ import { login } from "../../src/login";
37
+
38
+ describe("login public-key bootstrap", () => {
39
+ const originalCwd = process.cwd();
40
+ let workingDirectory: string;
41
+
42
+ beforeEach(() => {
43
+ jest.clearAllMocks();
44
+ jest.spyOn(console, "log").mockImplementation(() => undefined);
45
+ jest.spyOn(console, "warn").mockImplementation(() => undefined);
46
+ workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-login-"));
47
+ fs.mkdirSync(path.join(workingDirectory, ".knowhow"));
48
+ fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt"), "raw-jwt");
49
+ process.chdir(workingDirectory);
50
+ hasKeyPair.mockReturnValue(false);
51
+ browserLogin.mockResolvedValue(undefined);
52
+ getConfig.mockResolvedValue({ modelProviders: [] });
53
+ getOrCreatePublicKey.mockReturnValue({
54
+ publicKeyBase64: "raw-key",
55
+ fingerprint: "SHA256:key",
56
+ });
57
+ httpGet.mockResolvedValue({
58
+ data: {
59
+ orgId: "org-1",
60
+ user: {
61
+ email: "user@example.com",
62
+ orgs: [{ organizationId: "org-1", organization: { id: "org-1", name: "Org" } }],
63
+ },
64
+ },
65
+ });
66
+ });
67
+
68
+ afterEach(() => {
69
+ process.chdir(originalCwd);
70
+ fs.rmSync(workingDirectory, { recursive: true, force: true });
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ it("bootstraps the selected identity after PKCE without invalidating browser login on registration failure", async () => {
75
+ registerPublicKey.mockRejectedValueOnce(new Error("endpoint unavailable"));
76
+
77
+ await expect(login(false, "/keys/custom")).resolves.toBeUndefined();
78
+
79
+ expect(browserLogin).toHaveBeenCalledTimes(1);
80
+ expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/custom");
81
+ expect(registerPublicKey).toHaveBeenCalledWith(
82
+ "raw-jwt",
83
+ "raw-key",
84
+ "https://api.example"
85
+ );
86
+ expect(updateConfig).toHaveBeenCalledWith(
87
+ expect.objectContaining({ orgId: "org-1", modelProviders: [{ provider: "knowhow" }] })
88
+ );
89
+ });
90
+
91
+ it("safely falls back to browser PKCE when the selected key cannot authenticate", async () => {
92
+ hasKeyPair.mockReturnValue(true);
93
+ getConfig.mockResolvedValue({ orgId: "org-1", modelProviders: [] });
94
+ authenticateWithKey.mockRejectedValueOnce(new Error("old backend"));
95
+ registerPublicKey.mockResolvedValueOnce(undefined);
96
+
97
+ await expect(login(false, "/keys/dev")).resolves.toBeUndefined();
98
+
99
+ expect(authenticateWithKey).toHaveBeenCalledWith(
100
+ "org-1",
101
+ "https://api.example",
102
+ "/keys/dev"
103
+ );
104
+ expect(browserLogin).toHaveBeenCalledTimes(1);
105
+ expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/dev");
106
+ });
107
+
108
+ it("does not attempt key authentication without a configured organization", async () => {
109
+ hasKeyPair.mockReturnValue(true);
110
+ getConfig.mockResolvedValue({ modelProviders: [] });
111
+ registerPublicKey.mockResolvedValueOnce(undefined);
112
+
113
+ await expect(login(false, "/keys/dev")).resolves.toBeUndefined();
114
+
115
+ expect(hasKeyPair).not.toHaveBeenCalled();
116
+ expect(authenticateWithKey).not.toHaveBeenCalled();
117
+ expect(browserLogin).toHaveBeenCalledTimes(1);
118
+ });
119
+ });