@tyvm/knowhow 0.0.132 → 0.0.135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.132",
3
+ "version": "0.0.135",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -75,6 +75,13 @@ export abstract class BaseAgent implements IAgent {
75
75
 
76
76
  // Interrupt support: resolves the currently awaited tool call or completion
77
77
  private _interruptResolve: (() => void) | null = null;
78
+ // A monotonically-increasing token identifying the active interruptible window.
79
+ // Used to ensure a stale (slow) operation completing in the background cannot
80
+ // clobber a newer interruptible window's resolver.
81
+ private _interruptToken = 0;
82
+ // Set when interrupt() is called with no active window, so the next
83
+ // makeInterruptible fires immediately instead of dropping the interrupt.
84
+ private _pendingInterrupt = false;
78
85
 
79
86
  protected threads = [] as Message[][];
80
87
 
@@ -217,6 +224,11 @@ export abstract class BaseAgent implements IAgent {
217
224
  this.startTimeMs = Date.now();
218
225
  this.currentTaskId = taskId || this.startTimeMs.toString();
219
226
 
227
+ // Reset interrupt state so a queued/stale interrupt from a prior task
228
+ // can't fire against the new task.
229
+ this._interruptResolve = null;
230
+ this._pendingInterrupt = false;
231
+
220
232
  // Emit event for plugin integration
221
233
  const id = taskId || this.startTimeMs.toString();
222
234
  this.events.emit(this.eventTypes.agentNewTask, {
@@ -740,32 +752,43 @@ export abstract class BaseAgent implements IAgent {
740
752
  interruptValue: T
741
753
  ): Promise<T> {
742
754
  return new Promise<T>((resolve, reject) => {
743
- let interrupted = false;
744
-
745
- this._interruptResolve = () => {
746
- interrupted = true;
747
- this._interruptResolve = null;
748
- resolve(interruptValue);
755
+ // Each interruptible window gets a unique token. `settled` guards this
756
+ // specific window so it can only ever resolve/reject once, and the token
757
+ // ensures a stale (slow) background operation that finishes AFTER this
758
+ // window was interrupted cannot clobber a NEWER window's resolver.
759
+ const myToken = ++this._interruptToken;
760
+ let settled = false;
761
+
762
+ const finish = (fn: () => void) => {
763
+ if (settled) return;
764
+ settled = true;
765
+ // Only clear the shared resolver if it still belongs to this window.
766
+ if (this._interruptToken === myToken) {
767
+ this._interruptResolve = null;
768
+ }
769
+ fn();
749
770
  };
750
771
 
772
+ this._interruptResolve = () => finish(() => resolve(interruptValue));
773
+
774
+ // If an interrupt arrived while there was no active window, honor it now.
775
+ if (this._pendingInterrupt) {
776
+ this._pendingInterrupt = false;
777
+ finish(() => resolve(interruptValue));
778
+ return;
779
+ }
780
+
751
781
  promise
752
782
  .then((result) => {
753
- if (this._interruptResolve) {
754
- this._interruptResolve = null;
755
- resolve(result);
756
- }
783
+ // If this window was already interrupted/settled, drop the stale
784
+ // result — do NOT touch _interruptResolve (it may belong to a newer
785
+ // window now).
786
+ finish(() => resolve(result));
757
787
  })
758
788
  .catch((err) => {
759
- if (this._interruptResolve) {
760
- this._interruptResolve = null;
761
- }
762
- // Only swallow the error if interrupt() was explicitly called.
763
- // Otherwise re-throw so callers see the real error.
764
- if (interrupted) {
765
- resolve(interruptValue);
766
- } else {
767
- reject(err);
768
- }
789
+ // If the window was already interrupted, swallow the late error.
790
+ if (settled) return;
791
+ finish(() => reject(err));
769
792
  });
770
793
  });
771
794
  }
@@ -777,10 +800,29 @@ export abstract class BaseAgent implements IAgent {
777
800
  */
778
801
  interrupt(message = "User interrupted this action you were waiting on") {
779
802
  this.log(`Interrupting current operation: ${message}`);
803
+
804
+ // If the user supplied a real message with the poke (not the default
805
+ // interrupt reason), queue it as a pending user message so the agent
806
+ // actually sees what they said on its next step.
807
+ if (
808
+ message &&
809
+ message !== "User interrupted this action you were waiting on"
810
+ ) {
811
+ this.addPendingUserMessage({ role: "user", content: message });
812
+ }
813
+
780
814
  if (this._interruptResolve) {
781
815
  this._interruptResolve();
782
816
  } else {
783
- this.log("No active interruptible operation to interrupt", "warn");
817
+ // No active interruptible window right now (we're between the AI
818
+ // completion and the tool call, or in message processing). Queue the
819
+ // interrupt so the next makeInterruptible window fires immediately
820
+ // instead of silently dropping it.
821
+ this._pendingInterrupt = true;
822
+ this.log(
823
+ "No active interruptible operation — queued interrupt for next step",
824
+ "warn"
825
+ );
784
826
  }
785
827
  }
786
828
 
package/src/cli.ts CHANGED
@@ -61,6 +61,7 @@ import { addBehaviorsCommand } from "./commands/behaviors";
61
61
  import { addSkillsCommand } from "./commands/skills";
62
62
  import { addAgentsCommand } from "./commands/agents";
63
63
  import { addHashCommand } from "./commands/hash";
64
+ import { addSandboxCommand } from "./commands/sandbox";
64
65
 
65
66
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
66
67
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -129,6 +130,7 @@ async function main() {
129
130
  addSkillsCommand(program);
130
131
  addAgentsCommand(program);
131
132
  addHashCommand(program);
133
+ addSandboxCommand(program);
132
134
 
133
135
  // Load global modules early (before parse) so they can register CLI subcommands.
134
136
  // We pass only the Program in context — no services are spun up at this stage.
@@ -43,7 +43,10 @@ export function addHashCommand(program: Command): void {
43
43
  )
44
44
  .option(
45
45
  "--save",
46
- "Save the current hash after a successful build step."
46
+ "Save the current hash. In standalone mode, records the hash immediately (no command run). " +
47
+ "In --run mode, records the hash only if the command succeeds. " +
48
+ "Without --save in --run mode, the hash is never recorded — useful when --run is a " +
49
+ "side-effect trigger rather than the step that produces the tracked artifacts."
47
50
  )
48
51
  .option(
49
52
  "--run <command>",
@@ -143,12 +146,17 @@ export function addHashCommand(program: Command): void {
143
146
  process.exit(typeof err?.status === "number" ? err.status : 1);
144
147
  }
145
148
 
146
- // Command succeeded — record the hash of the inputs that produced it.
147
- if (!hashes[opts.name]) hashes[opts.name] = {};
148
- hashes[opts.name][opts.input] = currentHash;
149
- await saveHashes(hashes);
150
- if (opts.verbose) {
151
- console.error(`[knowhow hash] saved hash ${currentHash} for "${opts.name}"`);
149
+ // Command succeeded — only save the hash if --save was also passed.
150
+ // Without --save, the hash is NOT recorded so the command will re-run next time.
151
+ // Use --save when --run produces the artifacts that the hash tracks.
152
+ // Omit --save when --run is a side-effect trigger (e.g. snapshot regeneration).
153
+ if (opts.save) {
154
+ if (!hashes[opts.name]) hashes[opts.name] = {};
155
+ hashes[opts.name][opts.input] = currentHash;
156
+ await saveHashes(hashes);
157
+ if (opts.verbose) {
158
+ console.error(`[knowhow hash] saved hash ${currentHash} for "${opts.name}"`);
159
+ }
152
160
  }
153
161
  process.exit(0);
154
162
  }